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 7589fa5..c62a5da 100644 --- a/.zenodo.json +++ b/.zenodo.json @@ -31,9 +31,9 @@ "searchability" ], "title": "Imageomics Catalog", - "version": "4.2.0", + "version": "4.3.0", "license": "MIT", - "publication_date": "2026-05-22", + "publication_date": "2026-07-08", "grants": [ { "id": "021nxhr62::2118240" diff --git a/AGENTS.md b/AGENTS.md index 529a5e2..ec4160c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -31,10 +31,12 @@ Path | Purpose `public/tag-groups.js` | **Secondary customization point.** Canonical tag aliases; maps raw tags to normalized display tags. `index.html` | Static HTML shell; config values are applied dynamically. `style.css` | Custom styles; colors are set via CSS custom properties populated from `config.yaml`. -`main.js` | All application logic: config loading, API calls, filtering, sorting, and rendering. -`src/` | Pure utility modules imported by `main.js` and the build scripts. +`main.js` | Application orchestrator. Wires up event listeners and coordinates data flow between UI and API layers. +`src/api/` | Data fetching modules (`fetchCodeRepos.js`, `fetchHfRepos.js`, etc.). +`src/ui/` | DOM manipulation, HTML templating (`render.js`), and URL/State routing. +`src/utils/` | Pure utility functions (filtering, sorting, tag normalization). `scripts/` | Build-time Node scripts (`fetch-releases.js`, `export-tags.js`). -`tests/` | Vitest unit and integration tests. +`tests/` | Vitest unit and integration tests. Subdirectories mirror the `src/` folder layout. `docs/` | Developer-facing documentation. `.github/workflows/` | CI/CD: tests on PR, deploy on push to `main`, weekly tag scan. @@ -64,7 +66,7 @@ Spaces | `https://huggingface.co/spaces/{owner}/{repo}` The bulk `models?author={org}&full=true` list response does **not** return `cardData` for models. `cardData` (which contains `model_name`, `description`, tags, etc.) is only available when each model is fetched individually. -`main.js` handles this with an extra parallel fetch after the list call (search `Step 2: If we are fetching models`). **Any change to model fetching must preserve this secondary per-model call.** +`src/api/fetchHfRepos.js` handles this with an extra parallel fetch after the list call (search `Step 2: If we are fetching models`). **Any change to model fetching must preserve this secondary per-model call.** ### `cardData` Key Differences Across Resource Types @@ -74,7 +76,7 @@ Datasets | `cardData.pretty_name` | `cardData.description` Models | `cardData.model_name` | `cardData.model_description` Spaces | `cardData.title` | `cardData.description` -Code that accesses card metadata must account for all three shapes. The rendering function `renderHubItemCard` in `main.js` already does this with fallback chains. +Code that accesses card metadata must account for all three shapes. The rendering function `renderHubItemCard` in `src/ui/render.js` already does this with fallback chains. ## Testing @@ -90,9 +92,9 @@ npm run test:watch # watch mode Current test files: - `tests/validateConfig.test.js` — unit tests for `src/validateConfig.js` - `tests/config.integration.test.js` — confirms `public/config.yaml` passes validation -- `tests/filterAndSort.test.js` — unit tests for `src/filterAndSort.js` -- `tests/filterNewAdditionalEntries.test.js` — unit tests for `src/filterNewAdditionalEntries.js` -- `tests/normalizeTag.test.js` — unit tests for `src/normalizeTag.js` +- `tests/utils/filterAndSort.test.js` — unit tests for `src/utils/filterAndSort.js` +- `tests/utils/filterNewAdditionalEntries.test.js` — unit tests for `src/utils/filterNewAdditionalEntries.js` +- `tests/utils/normalizeTag.test.js` — unit tests for `src/utils/normalizeTag.js` When adding a new utility to `src/`, add a corresponding test file. Do **not** add tests that make live network calls. @@ -100,7 +102,8 @@ When adding a new utility to `src/`, add a corresponding test file. Do **not** a All runtime behavior is controlled by `public/config.yaml`. It is fetched at page load (not bundled), so changes take effect on the next page load without a rebuild. Key fields: -- `ORGANIZATION_NAME` — used in all API calls (must be lowercase) +- `ORGANIZATION_NAME` — used in all code repository platform API calls (GitHub is not case-sensitive) +- `HF_ORGANIZATION_NAME` — used in all Hugging Face API calls (**case-sensitive**) - `API_BASE_URL` — Hugging Face API base (default: `https://huggingface.co/api/`) - `PLATFORM` — code repository platform; only `github` is currently supported - `ADDITIONAL_REPOS` — forked or external GitHub repos to include @@ -117,11 +120,15 @@ npm run build # production build → dist/ npm run preview # preview production build locally ``` +## Contributing Workflow + +Changes should be made on a branch off of `dev`. All new features and development are submitted to the `dev` branch for PR review and staging before merging to `main`. Do not open pull requests to `main`; the existing GitHub Action Tag Scan workflow directed to `main` is allowed behavior, distinct from these instructions. + ## CI Workflows Workflow | Trigger | Purpose ---|---|--- -`test.yml` | PR to `main` | Runs `npm test` +`test.yml` | PR to `main` and `dev` | Runs `npm test` `deploy.yml` | Push to `main`, daily schedule, manual | Builds and deploys to GitHub Pages `weekly-tag-scan.yml` | Weekly schedule, manual, PR close cleanup | Detects new tags and opens a PR to update `tag-groups.js` `validate-zenodo.yaml` | Push (paths filter) | Validates `.zenodo.json` diff --git a/CITATION.cff b/CITATION.cff index d9e3118..6039bea 100644 --- a/CITATION.cff +++ b/CITATION.cff @@ -11,14 +11,14 @@ authors: given-names: "Balaji" affiliation: "The Ohio State University" cff-version: 1.2.0 -date-released: "2026-05-22" +date-released: "2026-07-08" identifiers: - - description: "The GitHub release URL of tag v4.2.0." + - description: "The GitHub release URL of tag v4.3.0." type: url - value: "https://github.com/Imageomics/catalog/releases/tag/v4.2.0" - - description: "The GitHub URL of the commit tagged with v4.2.0." + value: "https://github.com/Imageomics/catalog/releases/tag/v4.3.0" + - description: "The GitHub URL of the commit tagged with v4.3.0." type: url - value: "https://github.com/Imageomics/catalog/tree/238dbf5624b0fb9b61c795624d22b082f4591cd5" # Update on release + value: "https://github.com/Imageomics/catalog/tree/" # Update on release keywords: - imageomics - code @@ -37,6 +37,6 @@ license: MIT message: "If you use this software, please cite it as below." repository-code: "https://github.com/Imageomics/catalog" title: "Imageomics Catalog" -version: "4.2.0" +version: "4.3.0" doi: "10.5281/zenodo.17602801" type: software diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..3b3f1d3 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,73 @@ +# Contributing to the Catalog + +Thank you for your interest in contributing to the catalog! This document outlines the standards and guidelines for contributing to this template repository. + +## Overview + +This catalog is built with JavaScript and relies on API calls to code, data, model, and spaces repositories to populate the web app in real time. Deployment only requires a static site-capable web server. The [Imageomics instance](https://catalog.imageomics.org) is currently deployed via GitHub Pages. + +This is a template repository designed to be copied (with "Use this Template") and customized by different organizations to present their code, data, models, and spaces in a dynamic, searchable site. The default setup is for the Imageomics Institute's GitHub and Hugging Face products. + +## Getting Started + +Follow [development guidance](README.md#development-prerequisites) for local setup and formatting expectations. + +### Testing Changes + +Always test your changes locally with `npm test` before submitting a PR to ensure: + +- Content renders correctly. +- Fetching, sorting, and filtering appear as intended. +- No build errors occur. + +UI features should be tested locally through running a preview. + +## Coding Style, Conventions, and Project Structure + +Please refer to our [AGENTS.md](AGENTS.md). This also includes special notes on key differences between the repo types (datasets, models, and spaces) in Hugging Face API calls and returns, as well as important considerations for the templated design of this repository. + +## Contribution Process + +1. **Create an issue** describing the problem or necessary change **in your own words**. + - Check existing [issues](https://github.com/Imageomics/catalog/issues) first. + - If you'd like to work on an existing issue, please comment on it to express interest or describe your potential fix. +2. **Create a feature branch** from `dev`. + - It is important that a repo fork includes the `dev` branch content since updates are staged there. +3. **Make your changes** following the standards above. +4. **Test locally** with `npm test` +5. **Run linter/formatter** to ensure formatting consistency + - See [formatting standard](README.md#formatting-standard) +6. **Submit a pull request** with: + - Clear description of changes **in your own words**. + - Reference to related issue. + - Screenshots if UI changes are involved. + +> [!IMPORTANT] +> Please describe your changes or issues in your own words, not with AI-generated text. We understand that AI is sometimes used to help refine or improve writing; this is fine. AI has been used collaboratively in developing this template, and similar use is acceptable, though **AI use in PRs must be acknowledged and checked by the PR author**. + +### Pull Request Guidelines + +- Keep PRs focused on a single topic when possible. +- Include tests and documentation updates for changes, as needed. +- Follow commit message conventions (see below). +- Test that the site builds without errors. + +### Commit Message Guidelines + +Commit messages should be **descriptive** and the PR should be **self-contained** (code change accompanied by tests and documentation of new functionality). We use squash merges for this repository, so strict adherence to, e.g., the [Conventional Commits](https://www.conventionalcommits.org/) format for commit messages is not necessary, but helpful for PR titles. + +### Content Review + +When reviewing content: + +- Verify functionality and local build without errors. +- Check for consistency with existing setup, especially for end-user consistency. +- Test compatibility with various filter and sort options. + +## Code of Conduct + +Following the [Imageomics core values](https://imageomics.github.io/Imageomics-guide/CODE_OF_CONDUCT/#values), we expect contributors to be respectful and kind. + +--- + +Thank you for helping improve the Catalog! Your contributions help make research products more discoverable. diff --git a/README.md b/README.md index 28d54c3..b10087a 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ Repository for web-based catalog of Imageomics code, data, models, and spaces. This template catalog is designed to fetch live data from an organization's code repository platform (e.g., GitHub) API and dataset, model, and spaces repository data from the Hugging Face API to render a searchable, filterable catalog page as a static site via GitHub Pages. The default setup is for the Imageomics Institute's [GitHub](https://github.com/Imageomics) and [Hugging Face](https://huggingface.co/imageomics) organizations. -For those interested in creating a similar catalog website, instructions for use and personalization are provided below, under [How to Use this Template](#how-to-use-this-template). More background on this site's creation is provided in the [docs](docs/). +For those interested in creating a similar catalog website or contributing to this template, instructions for use and personalization are provided below, under [How to Use this Template](#how-to-use-this-template). More background on this site's creation is provided in the [docs](docs/). ## Features @@ -28,18 +28,25 @@ The site runs based on four primary files: * `public/config.yaml`: Contains all customizable settings including organization names, colors, branding, and API settings. This is the main file to edit for personalization. Placed in `public/` so Vite copies it to `dist/` without bundling, keeping it editable after deployment. * `index.html`: The main HTML file that provides the structure of the webpage and links to the CSS and JavaScript files. Config values are applied dynamically from `config.yaml`. * `style.css`: Custom styling for the application, including color schemes, layout, and animations. Colors are set via CSS custom properties that are populated from `config.yaml`. -* `main.js`: Handles the application's logic, including config loading, API calls, data filtering, sorting, and dynamic rendering of the catalog items. Relies on the build-time Node script `fetch-releases.js` for version-tag badge feature. +* `main.js`: Application manager, handles config loading, event listeners, and coordinates UI updates based on API fetches for dynamic rendering of the catalog items. +* `src/`: Modular application logic, organized by purpose: + * `api/`: Data fetching modules for external platforms (GitHub and Hugging Face). + * `ui/`: DOM manipulation, HTML templating, and URL/State routing. + * `utils/`: Utility functions such as data filtering, sorting, and tag (keyword) normalization. Two additional files support the build tooling: * `package.json`: Declares npm dependencies (`vite`, `tailwindcss`, `@tailwindcss/vite`) and defines the `dev`, `build`, and `preview` scripts. * `vite.config.js`: Vite configuration that registers the Tailwind CSS plugin. +* `scripts/fetch-releases.js`: Build-time Node script to fetch GitHub repo release data for version-tag badge feature. ## How to Use This Template This Catalog is set up as a template repository. To build a personalized version of the Catalog, select "Use this Template" at the top of the repo to generate your own version. This will create a new repository (generated from the template repo) that does not share the commit history of the template. Updates can still be added from the template upstream through `git cherry-pick`.[^1] [^1]: We recommend following the [Git Cherry-pick Guide](https://imageomics.github.io/Collaborative-distributed-science-guide/wiki-guide/Git-Cherry-Pick-Guide/) from the [Collaborative Distributed Science Guide](https://imageomics.github.io/Collaborative-distributed-science-guide/) for those unfamiliar with this process. +For those looking to contribute to the template, please read our [contributing guide](CONTRIBUTING.md) and fork the repository. + ### Personalizing Your Catalog The primary way to personalize this catalog is through the `config.yaml` file, which contains all customizable settings. After using the template, follow the [personalization guide](docs/personalization.md) to update the [config file](public/config.yaml), [package.json](package.json), and [tag groups](public/tag-groups.js) for your organization. diff --git a/docs/personalization.md b/docs/personalization.md index 3a6d161..cc26212 100644 --- a/docs/personalization.md +++ b/docs/personalization.md @@ -8,8 +8,9 @@ Welcome to your new catalog repo! The primary way to personalize this catalog is ### Organization & Repository Settings - * `ORGANIZATION_NAME`: Your GitHub/Hugging Face organization name (lowercase for API calls) - * `ORG_NAME`: Display name for your organization (can differ from API name); used as fallback site title if `CATALOG_TITLE` is not set + * `ORGANIZATION_NAME`: Your code platform (e.g., GitHub) organization name (for API calls) + * `HF_ORGANIZATION_NAME`: Your Hugging Face organization name (**case-sensitive**, for API calls) + * `ORG_NAME`: Display name for your organization (can differ from API name); used for logo alt-text and as fallback site title if `CATALOG_TITLE` is not set * `CATALOG_REPO_NAME`: Repository name for the catalog itself (used for stats badge) ### Branding diff --git a/docs/tag-grouping-process.md b/docs/tag-grouping-process.md index b69a121..e73b013 100644 --- a/docs/tag-grouping-process.md +++ b/docs/tag-grouping-process.md @@ -19,8 +19,7 @@ const TAG_GROUPS = { - The **key** (`"canonical tag"`) is the display tag shown in the UI filter dropdown. - The **value array** lists every raw API tag that should be normalized to that key. - Raw tags not present in any array pass through unchanged and appear as-is in the UI. -- Raw tags that contain a colon (e.g. `license:mit`, `format:parquet`) are automatically - filtered out as Hugging Face system metadata so they never reach the UI. This can be changed in [src/normalizeTag.js](../src/normalizeTag.js), by removing the marked option line. +- Raw tags that contain a colon (e.g. `license:mit`, `format:parquet`) are automatically filtered out as Hugging Face system metadata so they never reach the UI. This can be changed in [src/normalizeTag.js](../src/normalizeTag.js), by removing the marked option lines in `normalizeTag` and `filterDisplayTags`. - Raw tags are maintained and matched-against for keyword searching and do appear in repo cards. --- diff --git a/main.js b/main.js index bf6c297..3fe7cb4 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/defineApiUrls.js'; +import { filterItems, sortItems } from './src/utils/filterAndSort.js'; +import { fetchCodeRepos } from './src/api/fetchCodeRepos.js'; +import { fetchHfRepos } from './src/api/fetchHfRepos.js'; +import { fetchCatalogStats } from './src/api/fetchStats.js'; // Start fetching config immediately when the module loads (before DOMContentLoaded) // so the fetch is in-flight while the DOM is being parsed. @@ -20,30 +23,13 @@ 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 ORGANIZATION_NAME, HF_ORGANIZATION_NAME, CATALOG_REPO_NAME, PLATFORM, API_BASE_URL, REFRESH_INTERVAL_DAYS, ADDITIONAL_REPOS, ADDITIONAL_HF_REPOS; let ORG_API_URL, REPO_API_URL; -// Build a reverse lookup from TAG_GROUPS (defined in tag-groups.js): raw tag → [canonical tags] -// A raw tag may appear in multiple groups, so the value is an array. -const tagLookup = Object.create(null); -if (typeof TAG_GROUPS !== 'undefined') { - for (const [canonical, aliases] of Object.entries(TAG_GROUPS)) { - for (const alias of aliases) { - const key = alias.toLowerCase(); - if (tagLookup[key]) { - tagLookup[key].push(canonical); - } else { - tagLookup[key] = [canonical]; - } - } - } -} - - let releasesMap = {}; let allItems = { @@ -65,103 +51,8 @@ let fetchedData = { spaces: false }; -// Utility function to handle errors and display a user-friendly message -const handleError = (error, message) => { - console.error(message, error); - const itemList = document.getElementById('itemList'); - itemList.innerHTML = `
-

Error loading items.

-

${message}

-
`; -}; - -// -// SECTION 1B: URL PARAMETER HANDLING -// - -/** - * Parses URL parameters from both query string (?key=value) and hash (#key=value). - * Query parameters take precedence over hash parameters. - * @returns {Object} An object containing the parsed parameters. - */ -const parseUrlParams = () => { - const params = {}; - - // Parse query string parameters (e.g., ?type=datasets&q=fish) - const queryString = window.location.search; - const urlParams = new URLSearchParams(queryString); - - // Parse hash parameters (e.g., #type=datasets&q=fish) - const hash = window.location.hash.slice(1); // Remove the leading '#' - const hashParams = new URLSearchParams(hash); - - // Hash parameters first (lower precedence) - for (const [key, value] of hashParams) { - params[key] = value; - } - - // Query parameters override hash parameters (higher precedence) - for (const [key, value] of urlParams) { - params[key] = value; - } - - return params; -}; - -/** - * Updates the URL hash with the current filter state without triggering a page reload. - * @param {Object} state - The current state object with type, q, sort, tag properties. - */ -const updateUrlParams = (state) => { - const params = new URLSearchParams(); - - // Only add non-default values to the URL - if (state.type && state.type !== 'all') { - params.set('type', state.type); - } - if (state.q && state.q.trim() !== '') { - params.set('q', state.q); - } - if (state.sort && state.sort !== 'lastModified') { - params.set('sort', state.sort); - } - if (state.tag && state.tag !== '') { - params.set('tag', state.tag); - } - if (state.archived && state.archived !== 'active') { - params.set('archived', state.archived); - } - - const paramString = params.toString(); - const newHash = paramString ? `#${paramString}` : ''; - - // Build the new URL preserving the pathname and any query parameters when clearing hash - const baseUrl = window.location.pathname + window.location.search; - const newUrl = newHash ? baseUrl + newHash : baseUrl; - - // Update the URL without triggering a page reload - const currentUrl = window.location.pathname + window.location.search + window.location.hash; - if (currentUrl !== newUrl) { - history.replaceState(null, '', newUrl); - } -}; - -/** - * Gets the current filter state from form elements. - * @returns {Object} The current state object. - */ -const getCurrentState = () => { - return { - type: document.getElementById('repoType')?.value || 'all', - q: document.getElementById('searchInput')?.value || '', - sort: document.getElementById('sortBy')?.value || 'lastModified', - tag: document.getElementById('tagFilter')?.value || '', - archived: document.getElementById('archiveFilter')?.value || 'active' - }; -}; - // -// SECTION 2: DATA FETCHING LOGIC +// SECTION 2: DATA FETCHING // /** @@ -178,388 +69,47 @@ const fetchHubItems = async (repoType) => { const skeletons = document.querySelectorAll('.skeleton-card'); skeletons.forEach(s => s.classList.remove('hidden')); - try { - let items = []; - - // Platform-specific api requests for code - if (repoType === "code") { - if (fetchedData.code) return allItems.code // reuse if already fetched - - // Paginate through all public repos (Platform determines API max returns per page) - let allRepos = []; - let nextUrl = `${ORG_API_URL}`; - while (nextUrl) { - const ghResponse = await fetch(nextUrl); - - if (!ghResponse.ok) { - const platformDisplay = getPlatformDisplay(PLATFORM); - throw new Error(`${platformDisplay.displayName || PLATFORM} error: ${ghResponse.status}`); - } - - const page = await ghResponse.json(); - allRepos = allRepos.concat(page); - - // Parse the Link header to find the next page URL, if any - const linkHeader = ghResponse.headers.get('Link'); - const match = linkHeader && linkHeader.match(/<([^>]+)>;\s*rel="next"/); - nextUrl = match ? match[1] : null; - } - - // For org-owned entries in ADDITIONAL_REPOS, reuse data already in allRepos to avoid redundant API calls. - // Only fetch entries that belong to a different org (external repos). - const allReposByFullName = new Map(allRepos.map(r => [r.full_name, r])); - const toFetch = ADDITIONAL_REPOS.filter(ownerRepo => !allReposByFullName.has(ownerRepo)); - const fromAllRepos = ADDITIONAL_REPOS.map(ownerRepo => allReposByFullName.get(ownerRepo)).filter(Boolean); - - const fetchedExternalData = await Promise.all( - toFetch.map(ownerRepo => - fetch(`${REPO_API_URL}${ownerRepo}`) - .then(r => { - if (!r.ok) { - console.warn(`Failed to fetch additional repo "${ownerRepo}": HTTP ${r.status}`); - return null; - } - return r.json(); - }) - .catch(err => { - console.warn(`Network error fetching additional repo "${ownerRepo}":`, err); - return null; - }) - ) - ); - const additionalRepos = [...fromAllRepos, ...fetchedExternalData.filter(Boolean)]; - - // Keep only non-forks from org; deduplicate against additional repos by full_name - const orgRepoNames = new Set(additionalRepos.map(r => r.full_name)); - const orgNonForks = allRepos.filter(repo => repo.name !== ".github" && !repo.fork && !orgRepoNames.has(repo.full_name)); - - // Process additional repos and all remaining org non-forks to include metadata and 'new' flag as appropriate - items = [...additionalRepos, ...orgNonForks] - .map(repo => { - const createdAt = new Date(repo.created_at); - const lastModified = new Date(repo.updated_at); - const isNew = (new Date() - createdAt) / (1000 * 60 * 60 * 24) < REFRESH_INTERVAL_DAYS; - - const rawTags = (repo.topics || []).map(t => t.toLowerCase()); - const tags = [...new Set(rawTags.flatMap(t => normalizeTag(t, tagLookup)).filter(Boolean))]; - const displayTags = rawTags.filter(t => !t.includes(':')); - tags.forEach(tag => tagsMap.code.add(tag)); - - const release = releasesMap[repo.full_name] ?? null; - - return { - id: repo.full_name, // "Imageomics/", used as backup if can't get repo.name - repoType: "code", - createdAt, - lastModified, - isNew, - archived: repo.archived || false, - tags, - rawTags, - displayTags, - description: repo.description || "No description provided.", - html_url: repo.html_url, - hasNewRelease: release?.isNew ?? false, - latestReleaseUrl: release?.url ?? null, - latestReleaseTag: release?.tag ?? null, - cardData: { - pretty_name: repo.name, // , the one used for card title display - description: repo.description, - stars: repo.stargazers_count - } - }; - }); - - allItems.code = items; - fetchedData.code = true; - - skeletons.forEach(s => s.classList.add('hidden')); - - return items; - } + 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' }, @@ -715,6 +207,7 @@ document.addEventListener('DOMContentLoaded', async () => { // Assign module-scope variables used by all functions ORGANIZATION_NAME = CONFIG.ORGANIZATION_NAME; + HF_ORGANIZATION_NAME = CONFIG.HF_ORGANIZATION_NAME; CATALOG_REPO_NAME = CONFIG.CATALOG_REPO_NAME; PLATFORM = CONFIG.PLATFORM; ORG_API_URL = getPlatformApiUrls(PLATFORM, ORGANIZATION_NAME).org; @@ -724,29 +217,17 @@ document.addEventListener('DOMContentLoaded', async () => { 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`; + // 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 +289,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) // Load pre-built release data (written by scripts/fetch-releases.js at build time) releasesMap = await fetch('./releases.json') @@ -854,18 +330,3 @@ document.addEventListener('DOMContentLoaded', async () => { await applyFiltersAndSort(false); updateUrlParams(getCurrentState()); }); - -// -// THEME TOGGLE LOGIC -// -const themeToggleBtn = document.getElementById('themeToggleBtn'); - -themeToggleBtn.addEventListener('click', () => { - if (document.documentElement.classList.contains('dark')) { - document.documentElement.classList.remove('dark'); - localStorage.theme = 'light'; - } else { - document.documentElement.classList.add('dark'); - localStorage.theme = 'dark'; - } -}); diff --git a/package-lock.json b/package-lock.json index 1630ebf..b8da410 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,466 +1,281 @@ { "name": "catalog", - "version": "4.2.0", + "version": "4.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "catalog", - "version": "4.2.0", + "version": "4.3.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" - ], + "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": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz", - "integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz", - "integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz", - "integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz", - "integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz", - "integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], + "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/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" - ], + "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": [ - "freebsd" - ], + "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-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/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-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/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-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/@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-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/@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-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/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-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" + "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-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" - ], + "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-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" + "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/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" - ], + "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": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz", - "integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==", - "cpu": [ - "arm64" + "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_modules/@esbuild/netbsd-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz", - "integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" + "node": ">=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/@oxc-project/types": { + "version": "0.138.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.138.0.tgz", + "integrity": "sha512-1a7ZKmrRTCoN1XMZ4L0PyyqrMnrNlLyPuOkdSX2MZg7IiIGRUyurNhAm73ptDOraoBcIordsIGKNPKUzy3ZmfA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } }, - "node_modules/@rollup/rollup-android-arm64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz", - "integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==", + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.4.tgz", + "integrity": "sha512-EZLpf/8y7GXkkra90ML47kzik/GMP3EMcE9bPyHmRfxLC6z9+aW5A8poCsoxjrT5GfEcNAAvWwUHjvP1pUQkfw==", "cpu": [ "arm64" ], @@ -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,180 +437,135 @@ "optional": true, "os": [ "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz", - "integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==", - "cpu": [ - "arm64" ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz", - "integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==", + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.4.tgz", + "integrity": "sha512-B9Ial3Kv5sh0SHnB1g/QWcUQCEvCF6QKGAl4zXypYj65mVI+B4AhFBwPtSN7pDrJeIx8Z7zdy4ntx+wQABom7w==", "cpu": [ "arm64" ], "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz", - "integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==", - "cpu": [ - "loong64" + "libc": [ + "glibc" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz", - "integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==", - "cpu": [ - "loong64" ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz", - "integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==", + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.4.tgz", + "integrity": "sha512-lZVym0PuHE1KZ22gmFTC15lAkrg9iTszR617oYRB/iPY1A56ywoJzVKOJBKaot5RiikCObmur6pogpse3gRcng==", "cpu": [ - "ppc64" + "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ "linux" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz", - "integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==", + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.4.tgz", + "integrity": "sha512-t2DNiLJWNTbnEHyUzTumldML6ET4/g16467LZoDDJ3tSxGvguL5/NyC2lCsNKuyRycg9XeDQF5SSv+TNOhQEXg==", "cpu": [ "ppc64" ], "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz", - "integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==", - "cpu": [ - "riscv64" + "libc": [ + "glibc" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz", - "integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==", - "cpu": [ - "riscv64" ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz", - "integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==", + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.4.tgz", + "integrity": "sha512-0WIRnL1Uw4BvTZRLQt+PVgo6ZKTJadlC2btP+/EOXv2f/DWbY0rEgl+y834mIVwP1FkTlWVTrGGJXf12lru7EQ==", "cpu": [ "s390x" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ "linux" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz", - "integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==", + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.4.tgz", + "integrity": "sha512-JWtGshGfX+oENAKonoNkqEJX+7hC8yfhi9GUyPX1VX4mdh1y5r+ZiJLR5XzAB0aoP6s/PcILsGjKq8O0mm24bw==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ "linux" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz", - "integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==", + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.4.tgz", + "integrity": "sha512-rT6yQcxUuXs4CnbofqwHRRV0iem349rLMYpTjkgQGLjrY4ado/eDzwPZPTCgTOlF6Nkp8NEv70yLMTn6qkWxsQ==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ "linux" - ] - }, - "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz", - "integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==", - "cpu": [ - "x64" ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ] + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz", - "integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==", + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.4.tgz", + "integrity": "sha512-KXMGoboq5cyaCQjDA4GLuRiOwBQ0EyFnJoVViLeZ45/3rFItRODEr+NdsBcVpll40hhNArlm/speWGRvj08LzA==", "cpu": [ "arm64" ], @@ -805,54 +574,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 +627,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 +647,49 @@ "license": "MIT" }, "node_modules/@tailwindcss/node": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.1.tgz", - "integrity": "sha512-jlx6sLk4EOwO6hHe1oCGm1Q4AN/s0rSrTTPBGPM0/RQ6Uylwq17FuU8IeJJKEjtc6K6O07zsvP+gDO6MMWo7pg==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.2.tgz", + "integrity": "sha512-yWP/sqEcBLaD8JuA6zNwxoYKr75qxTioYwlRwekj5Jr/I5GXnoJfjetH/psLUIv74cYTH2lBUEzBkinthoYcBg==", "dev": true, "license": "MIT", "dependencies": { "@jridgewell/remapping": "^2.3.5", - "enhanced-resolve": "^5.19.0", - "jiti": "^2.6.1", - "lightningcss": "1.31.1", + "enhanced-resolve": "5.21.6", + "jiti": "^2.7.0", + "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", - "tailwindcss": "4.2.1" + "tailwindcss": "4.3.2" } }, "node_modules/@tailwindcss/oxide": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.2.1.tgz", - "integrity": "sha512-yv9jeEFWnjKCI6/T3Oq50yQEOqmpmpfzG1hcZsAOaXFQPfzWprWrlHSdGPEF3WQTi8zu8ohC9Mh9J470nT5pUw==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.2.tgz", + "integrity": "sha512-z8ZgnzX8gdNoWLBLqBPoh/sjnxkwvf9ZuWjnO0l0yIzbLa5/9S+eC5QxGZKRobVHIC3/1BoMWjHblqWjcgFgag==", "dev": true, "license": "MIT", "engines": { "node": ">= 20" }, "optionalDependencies": { - "@tailwindcss/oxide-android-arm64": "4.2.1", - "@tailwindcss/oxide-darwin-arm64": "4.2.1", - "@tailwindcss/oxide-darwin-x64": "4.2.1", - "@tailwindcss/oxide-freebsd-x64": "4.2.1", - "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.1", - "@tailwindcss/oxide-linux-arm64-gnu": "4.2.1", - "@tailwindcss/oxide-linux-arm64-musl": "4.2.1", - "@tailwindcss/oxide-linux-x64-gnu": "4.2.1", - "@tailwindcss/oxide-linux-x64-musl": "4.2.1", - "@tailwindcss/oxide-wasm32-wasi": "4.2.1", - "@tailwindcss/oxide-win32-arm64-msvc": "4.2.1", - "@tailwindcss/oxide-win32-x64-msvc": "4.2.1" + "@tailwindcss/oxide-android-arm64": "4.3.2", + "@tailwindcss/oxide-darwin-arm64": "4.3.2", + "@tailwindcss/oxide-darwin-x64": "4.3.2", + "@tailwindcss/oxide-freebsd-x64": "4.3.2", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.2", + "@tailwindcss/oxide-linux-arm64-gnu": "4.3.2", + "@tailwindcss/oxide-linux-arm64-musl": "4.3.2", + "@tailwindcss/oxide-linux-x64-gnu": "4.3.2", + "@tailwindcss/oxide-linux-x64-musl": "4.3.2", + "@tailwindcss/oxide-wasm32-wasi": "4.3.2", + "@tailwindcss/oxide-win32-arm64-msvc": "4.3.2", + "@tailwindcss/oxide-win32-x64-msvc": "4.3.2" } }, "node_modules/@tailwindcss/oxide-android-arm64": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.2.1.tgz", - "integrity": "sha512-eZ7G1Zm5EC8OOKaesIKuw77jw++QJ2lL9N+dDpdQiAB/c/B2wDh0QPFHbkBVrXnwNugvrbJFk1gK2SsVjwWReg==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.2.tgz", + "integrity": "sha512-WHxqIuHpvZ5VtdX6GTl1Ik/Vp2YuN42Et+0CdeaVd/frQ9jAvGmvR8vLT+jk3e8/Q3x8kECB9+R17pgpp2BulA==", "cpu": [ "arm64" ], @@ -928,9 +704,9 @@ } }, "node_modules/@tailwindcss/oxide-darwin-arm64": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.2.1.tgz", - "integrity": "sha512-q/LHkOstoJ7pI1J0q6djesLzRvQSIfEto148ppAd+BVQK0JYjQIFSK3JgYZJa+Yzi0DDa52ZsQx2rqytBnf8Hw==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.2.tgz", + "integrity": "sha512-GZypeUY/IDJW3877KeM+O67vbXr3MBnbtEL4aYhNErv/JWZhye2vGSWWG9tB6iiqR2MqRNkY8IOUy4NdSZV26w==", "cpu": [ "arm64" ], @@ -945,9 +721,9 @@ } }, "node_modules/@tailwindcss/oxide-darwin-x64": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.2.1.tgz", - "integrity": "sha512-/f/ozlaXGY6QLbpvd/kFTro2l18f7dHKpB+ieXz+Cijl4Mt9AI2rTrpq7V+t04nK+j9XBQHnSMdeQRhbGyt6fw==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.2.tgz", + "integrity": "sha512-UIIzmefR6KO1sDU7MzRqAxC8iBpft/VhkGjTjnhoS6k7Z3rQ9wEgA1ODSiyH/tcSYssulNm4Ci3hOeK1jH7ccQ==", "cpu": [ "x64" ], @@ -962,9 +738,9 @@ } }, "node_modules/@tailwindcss/oxide-freebsd-x64": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.2.1.tgz", - "integrity": "sha512-5e/AkgYJT/cpbkys/OU2Ei2jdETCLlifwm7ogMC7/hksI2fC3iiq6OcXwjibcIjPung0kRtR3TxEITkqgn0TcA==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.2.tgz", + "integrity": "sha512-GN+uAmcI6DNspnCDwtOAZrTz6oukJnp337qZvxqCGLd3BHBzJpO0ZbTLRvJNdztOeAmTzewewGIMPb0tk2R4WA==", "cpu": [ "x64" ], @@ -979,9 +755,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.2.1.tgz", - "integrity": "sha512-Uny1EcVTTmerCKt/1ZuKTkb0x8ZaiuYucg2/kImO5A5Y/kBz41/+j0gxUZl+hTF3xkWpDmHX+TaWhOtba2Fyuw==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.2.tgz", + "integrity": "sha512-4ABn7qSbdHRwTiDiuWNegCyb5+2FJ4vKIKc3DmKrvAFw7MU1Lm11dIkTPwUaFdTzc7IsOpDbqBrlh0x6y36U/w==", "cpu": [ "arm" ], @@ -996,13 +772,16 @@ } }, "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.2.1.tgz", - "integrity": "sha512-CTrwomI+c7n6aSSQlsPL0roRiNMDQ/YzMD9EjcR+H4f0I1SQ8QqIuPnsVp7QgMkC1Qi8rtkekLkOFjo7OlEFRQ==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.2.tgz", + "integrity": "sha512-wDgEIGwoM8w8pufh9LVt1PahDgNdKXrLC2qfAnV3vAmococ9RWbxeAw4pxPttd/TsJfwjyLf90Dg1y9y8I6Emw==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1013,13 +792,16 @@ } }, "node_modules/@tailwindcss/oxide-linux-arm64-musl": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.2.1.tgz", - "integrity": "sha512-WZA0CHRL/SP1TRbA5mp9htsppSEkWuQ4KsSUumYQnyl8ZdT39ntwqmz4IUHGN6p4XdSlYfJwM4rRzZLShHsGAQ==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.2.tgz", + "integrity": "sha512-J5Nuk0uZQIiMTJj3LEx4sAA9tMFUoXQZFv1J6An+QGYe53HKRJuFDi0rpq/tuouCZeAbOBY3kQ6g8qeD4TUjtA==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -1030,13 +812,16 @@ } }, "node_modules/@tailwindcss/oxide-linux-x64-gnu": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.2.1.tgz", - "integrity": "sha512-qMFzxI2YlBOLW5PhblzuSWlWfwLHaneBE0xHzLrBgNtqN6mWfs+qYbhryGSXQjFYB1Dzf5w+LN5qbUTPhW7Y5g==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.2.tgz", + "integrity": "sha512-kqCZpSKOBEJO4mz7OqWoofBZeXTAwaVGPj0ErAj7CojmhKpWVWVOnrt9dE8odoIraZq4oj3ausM37kXi+Tow8w==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1047,13 +832,16 @@ } }, "node_modules/@tailwindcss/oxide-linux-x64-musl": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.2.1.tgz", - "integrity": "sha512-5r1X2FKnCMUPlXTWRYpHdPYUY6a1Ar/t7P24OuiEdEOmms5lyqjDRvVY1yy9Rmioh+AunQ0rWiOTPE8F9A3v5g==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.2.tgz", + "integrity": "sha512-cixpqbh2toJDmkuCRI68nXA8ZxNmdK9Y+9v5h3MC3ZQKy/0BO8AWzlkWyRM7JAFSGBlfig4YVTPsK6MVgqz1uw==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -1064,9 +852,9 @@ } }, "node_modules/@tailwindcss/oxide-wasm32-wasi": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.2.1.tgz", - "integrity": "sha512-MGFB5cVPvshR85MTJkEvqDUnuNoysrsRxd6vnk1Lf2tbiqNlXpHYZqkqOQalydienEWOHHFyyuTSYRsLfxFJ2Q==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.2.tgz", + "integrity": "sha512-4ec2Z/LOmRsAgU23CS4xeJfcJlmRg94A/XrbGRCF1gyU/zdDfRLYDVsS+ynSZCmGNxQ1jQriQOKMQeQxBA3Isw==", "bundleDependencies": [ "@napi-rs/wasm-runtime", "@emnapi/core", @@ -1082,85 +870,21 @@ "license": "MIT", "optional": true, "dependencies": { - "@emnapi/core": "^1.8.1", - "@emnapi/runtime": "^1.8.1", - "@emnapi/wasi-threads": "^1.1.0", - "@napi-rs/wasm-runtime": "^1.1.1", - "@tybys/wasm-util": "^0.10.1", + "@emnapi/core": "^1.11.1", + "@emnapi/runtime": "^1.11.1", + "@emnapi/wasi-threads": "^1.2.2", + "@napi-rs/wasm-runtime": "^1.1.4", + "@tybys/wasm-util": "^0.10.2", "tslib": "^2.8.1" }, "engines": { "node": ">=14.0.0" } }, - "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": { - "version": "1.8.1", - "dev": true, - "inBundle": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/wasi-threads": "1.1.0", - "tslib": "^2.4.0" - } - }, - "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": { - "version": "1.8.1", - "dev": true, - "inBundle": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": { - "version": "1.1.0", - "dev": true, - "inBundle": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": { - "version": "1.1.1", - "dev": true, - "inBundle": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/core": "^1.7.1", - "@emnapi/runtime": "^1.7.1", - "@tybys/wasm-util": "^0.10.1" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Brooooooklyn" - } - }, - "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": { - "version": "0.10.1", - "dev": true, - "inBundle": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": { - "version": "2.8.1", - "dev": true, - "inBundle": true, - "license": "0BSD", - "optional": true - }, "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.1.tgz", - "integrity": "sha512-YlUEHRHBGnCMh4Nj4GnqQyBtsshUPdiNroZj8VPkvTZSoHsilRCwXcVKnG9kyi0ZFAS/3u+qKHBdDc81SADTRA==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.2.tgz", + "integrity": "sha512-Zyr/M0+XcYZu3bZrUytc7TXvrk0ftWfl8gN2MwekNDzhqhKRUucMPSeOzM0o0wH5AWOU49BsKRrfKxI2atCPMQ==", "cpu": [ "arm64" ], @@ -1175,9 +899,9 @@ } }, "node_modules/@tailwindcss/oxide-win32-x64-msvc": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.2.1.tgz", - "integrity": "sha512-rbO34G5sMWWyrN/idLeVxAZgAKWrn5LiR3/I90Q9MkA67s6T1oB0xtTe+0heoBvHSpbU9Mk7i6uwJnpo4u21XQ==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.2.tgz", + "integrity": "sha512-QI9BO7KlNZsp2GuO0jwAAj5jCDABOKXRkCk2XuKTSaNEFSdfzqswYVTtCHBNKHLsqyjFyFkqlDiwkNbTYSssMQ==", "cpu": [ "x64" ], @@ -1192,18 +916,29 @@ } }, "node_modules/@tailwindcss/vite": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.2.1.tgz", - "integrity": "sha512-TBf2sJjYeb28jD2U/OhwdW0bbOsxkWPwQ7SrqGf9sVcoYwZj7rkXljroBO9wKBut9XnmQLXanuDUeqQK0lGg/w==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.2.tgz", + "integrity": "sha512-eHpMeX4JXfVNJDEcsouTeCBubJBTcTLigeaw/NTUW6PB5ATKKXdyonnXgTBX2VuRbjz1hjfz6C5XAhr52ImQXA==", "dev": true, "license": "MIT", "dependencies": { - "@tailwindcss/node": "4.2.1", - "@tailwindcss/oxide": "4.2.1", - "tailwindcss": "4.2.1" + "@tailwindcss/node": "4.3.2", + "@tailwindcss/oxide": "4.3.2", + "tailwindcss": "4.3.2" }, "peerDependencies": { - "vite": "^5.2.0 || ^6 || ^7" + "vite": "^5.2.0 || ^6 || ^7 || ^8" + } + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", + "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" } }, "node_modules/@types/chai": { @@ -1360,6 +1095,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 +1122,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 +1168,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 +1201,6 @@ "dev": true, "license": "MIT" }, - "node_modules/esbuild": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", - "integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.27.3", - "@esbuild/android-arm": "0.27.3", - "@esbuild/android-arm64": "0.27.3", - "@esbuild/android-x64": "0.27.3", - "@esbuild/darwin-arm64": "0.27.3", - "@esbuild/darwin-x64": "0.27.3", - "@esbuild/freebsd-arm64": "0.27.3", - "@esbuild/freebsd-x64": "0.27.3", - "@esbuild/linux-arm": "0.27.3", - "@esbuild/linux-arm64": "0.27.3", - "@esbuild/linux-ia32": "0.27.3", - "@esbuild/linux-loong64": "0.27.3", - "@esbuild/linux-mips64el": "0.27.3", - "@esbuild/linux-ppc64": "0.27.3", - "@esbuild/linux-riscv64": "0.27.3", - "@esbuild/linux-s390x": "0.27.3", - "@esbuild/linux-x64": "0.27.3", - "@esbuild/netbsd-arm64": "0.27.3", - "@esbuild/netbsd-x64": "0.27.3", - "@esbuild/openbsd-arm64": "0.27.3", - "@esbuild/openbsd-x64": "0.27.3", - "@esbuild/openharmony-arm64": "0.27.3", - "@esbuild/sunos-x64": "0.27.3", - "@esbuild/win32-arm64": "0.27.3", - "@esbuild/win32-ia32": "0.27.3", - "@esbuild/win32-x64": "0.27.3" - } - }, "node_modules/estree-walker": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", @@ -1510,10 +1261,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 +1292,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 +1371,23 @@ "url": "https://opencollective.com/parcel" }, "optionalDependencies": { - "lightningcss-android-arm64": "1.31.1", - "lightningcss-darwin-arm64": "1.31.1", - "lightningcss-darwin-x64": "1.31.1", - "lightningcss-freebsd-x64": "1.31.1", - "lightningcss-linux-arm-gnueabihf": "1.31.1", - "lightningcss-linux-arm64-gnu": "1.31.1", - "lightningcss-linux-arm64-musl": "1.31.1", - "lightningcss-linux-x64-gnu": "1.31.1", - "lightningcss-linux-x64-musl": "1.31.1", - "lightningcss-win32-arm64-msvc": "1.31.1", - "lightningcss-win32-x64-msvc": "1.31.1" + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" } }, "node_modules/lightningcss-android-arm64": { - "version": "1.31.1", - "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.31.1.tgz", - "integrity": "sha512-HXJF3x8w9nQ4jbXRiNppBCqeZPIAfUo8zE/kOEGbW5NZvGc/K7nMxbhIr+YlFlHW5mpbg/YFPdbnCh1wAXCKFg==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", "cpu": [ "arm64" ], @@ -1584,9 +1406,9 @@ } }, "node_modules/lightningcss-darwin-arm64": { - "version": "1.31.1", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.31.1.tgz", - "integrity": "sha512-02uTEqf3vIfNMq3h/z2cJfcOXnQ0GRwQrkmPafhueLb2h7mqEidiCzkE4gBMEH65abHRiQvhdcQ+aP0D0g67sg==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", "cpu": [ "arm64" ], @@ -1605,9 +1427,9 @@ } }, "node_modules/lightningcss-darwin-x64": { - "version": "1.31.1", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.31.1.tgz", - "integrity": "sha512-1ObhyoCY+tGxtsz1lSx5NXCj3nirk0Y0kB/g8B8DT+sSx4G9djitg9ejFnjb3gJNWo7qXH4DIy2SUHvpoFwfTA==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", "cpu": [ "x64" ], @@ -1626,9 +1448,9 @@ } }, "node_modules/lightningcss-freebsd-x64": { - "version": "1.31.1", - "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.31.1.tgz", - "integrity": "sha512-1RINmQKAItO6ISxYgPwszQE1BrsVU5aB45ho6O42mu96UiZBxEXsuQ7cJW4zs4CEodPUioj/QrXW1r9pLUM74A==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", "cpu": [ "x64" ], @@ -1647,9 +1469,9 @@ } }, "node_modules/lightningcss-linux-arm-gnueabihf": { - "version": "1.31.1", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.31.1.tgz", - "integrity": "sha512-OOCm2//MZJ87CdDK62rZIu+aw9gBv4azMJuA8/KB74wmfS3lnC4yoPHm0uXZ/dvNNHmnZnB8XLAZzObeG0nS1g==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", "cpu": [ "arm" ], @@ -1668,13 +1490,16 @@ } }, "node_modules/lightningcss-linux-arm64-gnu": { - "version": "1.31.1", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.31.1.tgz", - "integrity": "sha512-WKyLWztD71rTnou4xAD5kQT+982wvca7E6QoLpoawZ1gP9JM0GJj4Tp5jMUh9B3AitHbRZ2/H3W5xQmdEOUlLg==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -1689,13 +1514,16 @@ } }, "node_modules/lightningcss-linux-arm64-musl": { - "version": "1.31.1", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.31.1.tgz", - "integrity": "sha512-mVZ7Pg2zIbe3XlNbZJdjs86YViQFoJSpc41CbVmKBPiGmC4YrfeOyz65ms2qpAobVd7WQsbW4PdsSJEMymyIMg==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -1710,13 +1538,16 @@ } }, "node_modules/lightningcss-linux-x64-gnu": { - "version": "1.31.1", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.31.1.tgz", - "integrity": "sha512-xGlFWRMl+0KvUhgySdIaReQdB4FNudfUTARn7q0hh/V67PVGCs3ADFjw+6++kG1RNd0zdGRlEKa+T13/tQjPMA==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -1731,13 +1562,16 @@ } }, "node_modules/lightningcss-linux-x64-musl": { - "version": "1.31.1", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.31.1.tgz", - "integrity": "sha512-eowF8PrKHw9LpoZii5tdZwnBcYDxRw2rRCyvAXLi34iyeYfqCQNA9rmUM0ce62NlPhCvof1+9ivRaTY6pSKDaA==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -1752,9 +1586,9 @@ } }, "node_modules/lightningcss-win32-arm64-msvc": { - "version": "1.31.1", - "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.31.1.tgz", - "integrity": "sha512-aJReEbSEQzx1uBlQizAOBSjcmr9dCdL3XuC/6HLXAxmtErsj2ICo5yYggg1qOODQMtnjNQv2UHb9NpOuFtYe4w==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", "cpu": [ "arm64" ], @@ -1773,9 +1607,9 @@ } }, "node_modules/lightningcss-win32-x64-msvc": { - "version": "1.31.1", - "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.31.1.tgz", - "integrity": "sha512-I9aiFrbd7oYHwlnQDqr1Roz+fTz61oDDJX7n9tYF9FJymH1cIN1DtKw3iYt6b8WZgEjoNwVSncwF4wx/ZedMhw==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", "cpu": [ "x64" ], @@ -1793,6 +1627,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 +1647,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 +1684,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 +1725,9 @@ } }, "node_modules/postcss": { - "version": "8.5.12", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.12.tgz", - "integrity": "sha512-W62t/Se6rA0Az3DfCL0AqJwXuKwBeYg6nOaIgzP+xZ7N5BFCI7DYi1qs6ygUYT6rvfi6t9k65UMLJC+PHZpDAA==", + "version": "8.5.16", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz", + "integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==", "dev": true, "funding": [ { @@ -1881,7 +1745,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.11", + "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -1889,49 +1753,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 +1851,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 +1897,14 @@ } }, "node_modules/tinyglobby": { - "version": "0.2.15", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", - "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", "dev": true, "license": "MIT", "dependencies": { "fdir": "^6.5.0", - "picomatch": "^4.0.3" + "picomatch": "^4.0.4" }, "engines": { "node": ">=12.0.0" @@ -2030,19 +1923,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 +2014,10 @@ }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.3.0", + "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", - "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", @@ -2073,13 +2030,16 @@ "@types/node": { "optional": true }, - "jiti": { + "@vitejs/devtools": { "optional": true }, - "less": { + "esbuild": { + "optional": true + }, + "jiti": { "optional": true }, - "lightningcss": { + "less": { "optional": true }, "sass": { @@ -2195,6 +2155,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 +2219,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 b33ad85..cbc6cf7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "catalog", - "version": "4.2.0", + "version": "4.3.0", "description": "Repository for web-based Imageomics code, data, model, and spaces catalog. This catalog is designed to use the GitHub API for searching all code repositories created under the [Imageomics GitHub Organization](https://github.com/Imageomics) and the Hugging Face API for searching all dataset, model, and spaces repositories created under the [Imageomics Hugging Face Organization](https://huggingface.co/imageomics).", "main": "main.js", "scripts": { @@ -40,12 +40,13 @@ }, "homepage": "https://github.com/Imageomics/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 17ef860..06b960d 100644 --- a/public/config.yaml +++ b/public/config.yaml @@ -2,8 +2,9 @@ # Customize these values to personalize the catalog for your organization # Organization & Repository Settings -ORGANIZATION_NAME: imageomics # GitHub/Hugging Face organization name (lowercase for API calls) -ORG_NAME: Imageomics # Display name for GitHub organization (can differ from API name) +ORGANIZATION_NAME: imageomics # 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: catalog # Repository name for the catalog itself (used for stats badge) # Branding @@ -43,6 +44,7 @@ ADDITIONAL_REPOS: # Array of Hugging Face repos from outside the org to include. # Each entry must specify "repo" (owner/name) and "type" (datasets, models, or spaces). +# Organization names are case-sensitive in the Hugging Face API. # ADDITIONAL_HF_REPOS: # - repo: "user/dataset-name" # type: "datasets" diff --git a/scripts/export-tags.js b/scripts/export-tags.js index f3ec2ba..7ab2497 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 { getPlatformApiUrls } from '../src/utils/defineApiUrls.js'; +import { getPlatformDisplay } from '../src/utils/defineRibbonVals.js'; +import { filterNewAdditionalEntries } from '../src/utils/filterNewAdditionalEntries.js'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); @@ -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,7 +35,7 @@ if (errors.length) { } const CONFIG = rawConfig; -const { ORGANIZATION_NAME, PLATFORM, API_BASE_URL, ADDITIONAL_REPOS } = CONFIG; +const { ORGANIZATION_NAME, HF_ORGANIZATION_NAME, PLATFORM, API_BASE_URL, ADDITIONAL_REPOS } = CONFIG; const ADDITIONAL_HF_REPOS = CONFIG.ADDITIONAL_HF_REPOS; // --------------------------------------------------------------------------- @@ -97,7 +97,7 @@ const collectCodePlatformTags = async () => { const collectHFTags = async (repoType) => { console.log(`Fetching HF ${repoType}...`); - let items = (await get(`${API_BASE_URL}${repoType}?author=${ORGANIZATION_NAME}&full=true`)).json; + let items = (await get(`${API_BASE_URL}${repoType}?author=${HF_ORGANIZATION_NAME}&full=true`)).json; // Fetch additional HF repos of this type const additionalForType = ADDITIONAL_HF_REPOS.filter(entry => entry.type === repoType); @@ -145,7 +145,7 @@ const collectHFTags = async (repoType) => { const sorted = Array.from(allTags).sort(); const outPath = path.resolve(__dirname, 'tag-export.txt'); - fs.writeFileSync(outPath, sorted.join('\n') + '\n', 'utf8'); + writeFileSync(outPath, sorted.join('\n') + '\n', 'utf8'); console.log(`\nDone. ${sorted.length} unique tags written to scripts/tag-export.txt`); } catch (err) { diff --git a/scripts/fetch-releases.js b/scripts/fetch-releases.js index e868f25..933ce34 100644 --- a/scripts/fetch-releases.js +++ b/scripts/fetch-releases.js @@ -4,16 +4,16 @@ 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 { getPlatformApiUrls } from '../src/utils/defineApiUrls.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) { diff --git a/src/api/fetchCodeRepos.js b/src/api/fetchCodeRepos.js new file mode 100644 index 0000000..a427a2b --- /dev/null +++ b/src/api/fetchCodeRepos.js @@ -0,0 +1,118 @@ +import { handleError } from '../ui/render.js'; +import { normalizeTag, filterDisplayTags } from '../utils/normalizeTag.js'; +import { getPlatformDisplay } from '../utils/defineRibbonVals.js'; + +/** + * Function for fetching code repositories from the specified platform (GitHub, GitLab, or Codeberg). + * Both org-owned (non-forks) and additional repos are fetched; metadata is processed for each repo. + * It also determines if a repo is "new" based on the provided refresh interval. + * @async + * @param {string} platform - 'github', pending: 'gitlab', or 'codeberg' + * @param {Array} additionalRepos - An array of additional "owner/repo" strings to include in addition to non-forked + * org repos. + * @param {string} orgApiUrl - The API URL for fetching organization repos + * @param {string} repoApiUrl - The API URL for fetching individual repo details + * @param {number} refreshIntervalDays - The cutoff in days for determining if a repo is "new" + * @param {Record} releasesMap - + * Release information for repos, keyed by full_name + * @returns {Promise} processedItems - A promise resolving to an array of code repositories + */ +export async function fetchCodeRepos( + platform, + additionalRepos, + orgApiUrl, + repoApiUrl, + refreshIntervalDays, + releasesMap +) { + + let allRepos = []; + let nextUrl = `${orgApiUrl}`; + try { + while (nextUrl) { + const ghResponse = await fetch(nextUrl); + + if (!ghResponse.ok) { + const platformDisplay = getPlatformDisplay(platform); + throw new Error(`${platformDisplay.displayName || platform} error: ${ghResponse.status}`); + } + + const page = await ghResponse.json(); + allRepos = allRepos.concat(page); + + // Parse the Link header to find the next page URL, if any + const linkHeader = ghResponse.headers.get('Link'); + const match = linkHeader && linkHeader.match(/<([^>]+)>;\s*rel="next"/); + nextUrl = match ? match[1] : null; + } + + // For org-owned entries in additionalRepos, reuse data already in allRepos to avoid redundant API calls. + // Only fetch entries that belong to a different org (external repos). + const allReposByFullName = new Map(allRepos.map(r => [r.full_name, r])); + const toFetch = additionalRepos.filter(ownerRepo => !allReposByFullName.has(ownerRepo)); + const fromAllRepos = additionalRepos.map(ownerRepo => allReposByFullName.get(ownerRepo)).filter(Boolean); + + const fetchedExternalData = await Promise.all( + toFetch.map(ownerRepo => + fetch(`${repoApiUrl}${ownerRepo}`) + .then(r => { + if (!r.ok) { + console.warn(`Failed to fetch additional repo "${ownerRepo}": HTTP ${r.status}`); + return null; + } + return r.json(); + }) + .catch(err => { + console.warn(`Network error fetching additional repo "${ownerRepo}":`, err); + return null; + }) + ) + ); + const filteredAdditionalRepos = [...fromAllRepos, ...fetchedExternalData.filter(Boolean)]; + + // Keep only non-forks from org; deduplicate against additional repos by full_name + const orgRepoNames = new Set(filteredAdditionalRepos.map(r => r.full_name)); + const orgNonForks = allRepos.filter(repo => repo.name !== ".github" && !repo.fork && !orgRepoNames.has(repo.full_name)); + + // Process additional repos and all remaining org non-forks to include metadata and 'new' flag as appropriate + let processedItems = [...filteredAdditionalRepos, ...orgNonForks] + .map(repo => { + const createdAt = new Date(repo.created_at); + const lastModified = new Date(repo.updated_at); + const isNew = (new Date() - createdAt) / (1000 * 60 * 60 * 24) < refreshIntervalDays; + + const rawTags = (repo.topics || []).map(t => t.toLowerCase()); + const tags = [...new Set(rawTags.flatMap(t => normalizeTag(t)).filter(Boolean))]; + const displayTags = filterDisplayTags(rawTags); + + const release = releasesMap[repo.full_name] ?? null; + + return { + id: repo.full_name, // "Imageomics/", used as backup if can't get repo.name + repoType: "code", + createdAt, + lastModified, + isNew, + archived: repo.archived || false, + tags, + rawTags, + displayTags, + description: repo.description || "No description provided.", + html_url: repo.html_url, + hasNewRelease: release?.isNew ?? false, + latestReleaseUrl: release?.url ?? null, + latestReleaseTag: release?.tag ?? null, + cardData: { + pretty_name: repo.name, // , the one used for card title display + description: repo.description, + stars: repo.stargazers_count || 0 + } + }; + }); + + return processedItems; + } catch (error) { + handleError(error, `Failed to fetch code from ${platform}. Please check your network connection or the API.`); + return []; + } +} diff --git a/src/api/fetchHfRepos.js b/src/api/fetchHfRepos.js new file mode 100644 index 0000000..45fb7da --- /dev/null +++ b/src/api/fetchHfRepos.js @@ -0,0 +1,105 @@ +import { normalizeTag, filterDisplayTags } from '../utils/normalizeTag.js'; +import { handleError } from '../ui/render.js'; +import { filterNewAdditionalEntries } from '../utils/filterNewAdditionalEntries.js'; + +/** + * Function for fetching Hugging Face repositories (models, datasets, or spaces) from the specified organization. + * It fetches both org-owned and additional repos, processes metadata, and determines if a repo is "new" based on the provided refresh interval. + * @async + * @param {string} repoType - The type of repository to fetch ("datasets", "models", or "spaces"). + * @param {Array} additionalHfRepos - Array of Hugging Face repos, by type to include in addition to org-owned repos + * @param {string} apiBaseUrl - The base URL for the Hugging Face API + * @param {string} hfOrgName - The Hugging Face organization name + * @param {number} refreshIntervalDays - The cutoff in days for determining if a repo is "new" + * @returns {Promise} processedItems - A promise resolving to an array of Hugging Face repositories with metadata + */ +export async function fetchHfRepos( + repoType, + additionalHfRepos, + apiBaseUrl, + hfOrgName, + refreshIntervalDays +) { + + try { + // hugging face api requests for datasets/models/spaces + const response = await fetch(`${apiBaseUrl}${repoType}?author=${hfOrgName}&full=true`); + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + let hfItems = await response.json(); + + // Fetch additional HF repos of this type from outside the org + const additionalForType = additionalHfRepos.filter(entry => entry.type === repoType); + if (additionalForType.length) { + const existingIds = new Set(hfItems.map(item => item.id)); + const toFetch = filterNewAdditionalEntries(existingIds, additionalForType); + + const fetched = await Promise.all( + toFetch.map(entry => + fetch(`${apiBaseUrl}${repoType}/${entry.repo}`) + .then(r => { + if (!r.ok) { + console.warn(`Failed to fetch additional HF repo "${entry.repo}": HTTP ${r.status}`); + return null; + } + return r.json(); + }) + .catch(err => { + console.warn(`Network error fetching additional HF repo "${entry.repo}":`, err); + return null; + }) + ) + ); + hfItems = [...hfItems, ...fetched.filter(item => item && !existingIds.has(item.id))]; + } + + // Step 2: If we are fetching models, get the full details for each one. + if (repoType === 'models') { + const detailPromises = hfItems.map(item => + fetch(`${apiBaseUrl}models/${item.id}`).then(res => { + if (!res.ok) { + console.error(`Failed to fetch details for ${item.id}`); + return null; // Return null for failed requests + } + return res.json(); + }) + ); + + // Wait for all detail requests to complete in parallel. + const detailedItems = await Promise.all(detailPromises); + + // Filter out any models that failed to fetch and assign the detailed list. + hfItems = detailedItems.filter(Boolean); + } + // Process the data to include metadata and a 'new' flag + const processedItems = hfItems.map(item => { + const createdAt = new Date(item.createdAt); + const lastModified = new Date(item.lastModified); + const isNew = (new Date() - createdAt) / (1000 * 60 * 60 * 24) < refreshIntervalDays; + + // Extract tags from the YAML metadata (handling different structures) + const rawTags = (item.cardData?.tags || item.tags || []).map(t => String(t).toLowerCase()); + const tags = [...new Set(rawTags.flatMap(t => normalizeTag(t)).filter(Boolean))]; + const displayTags = filterDisplayTags(rawTags); + + return { + ...item, + repoType, + createdAt, + lastModified, + isNew, + archived: false, + likes: item.likes || 0, + tags, + rawTags, + displayTags + }; + }); + + return processedItems; + } catch (error) { + handleError(error, `Failed to fetch ${repoType} from Hugging Face. Please check your network connection or the API.`); + return []; + } +} diff --git a/src/api/fetchStats.js b/src/api/fetchStats.js new file mode 100644 index 0000000..fc0c0bd --- /dev/null +++ b/src/api/fetchStats.js @@ -0,0 +1,38 @@ +/** + * Fetches statistics for the Catalog repository itself (Stars, Forks, Version) + * populates the badge in the top right corner. + * @param {string} repoApiUrl - The base API URL for the code platform (e.g., GitHub API URL) + * @param {string} organizationName - The organization name for the catalog repository + * @param {string} catalogRepoName - The repository name for the catalog itself + * @returns {Promise} - A promise that resolves when the stats have been fetched and displayed + */ +export const fetchCatalogStats = async (repoApiUrl, organizationName, catalogRepoName) => { + // Helper: Updates text, shows the specific stat, and ensures the divider is visible + const update = (textId, containerId, value) => { + const el = document.getElementById(textId); + const container = document.getElementById(containerId); + if (el && container && value !== undefined) { + el.innerText = value; + if (value != 0) { + container.classList.remove('hidden'); + container.classList.add('flex'); + } + } + }; + + try { + //TODO: Update stars and forks to support other platforms (GitLab, Codeberg) once implemented + // 1. Get Stars & Forks + const repo = await fetch(`${repoApiUrl}${organizationName}/${catalogRepoName}`).then(r => r.ok ? r.json() : {}); + if (repo.stargazers_count !== undefined) update('gh-stars', 'gh-star-container', repo.stargazers_count); + if (repo.forks_count !== undefined) update('gh-forks', 'gh-fork-container', repo.forks_count); + + // 2. Get Version (Tag) + // TODO: Import from package.json + const release = await fetch(`${repoApiUrl}${organizationName}/${catalogRepoName}/releases/latest`).then(r => r.ok ? r.json() : {}); + if (release.tag_name) update('gh-tag', 'gh-version-container', release.tag_name); + + } catch (e) { + console.warn("Could not fetch Code Repo stats", e); + } +}; diff --git a/src/normalizeTag.js b/src/normalizeTag.js deleted file mode 100644 index 56d7ba0..0000000 --- a/src/normalizeTag.js +++ /dev/null @@ -1,18 +0,0 @@ -/** - * Normalizes a raw tag string. - * - Returns null for Hugging Face system metadata tags (tags containing a colon). - * - Maps known aliases to their canonical tag(s) via tagLookup. - * - Falls back to the lowercased original if no mapping exists. - * @param {string} tag - * @param {Object} tagLookup - Reverse lookup map: lowercased alias → [canonical tags] - * @returns {string[]|null} - */ -export function normalizeTag(tag, tagLookup) { - const lower = String(tag).toLowerCase(); - // OPTION LINE -- REMOVE IF UNWANTED - // Removes Hugging Face auto-generated system tags (e.g. "license:mit", "format:parquet"). - // These are identified by the presence of a colon. To include auto-generated tags in the - // catalog, remove the following line. - if (lower.includes(':')) return null; - return tagLookup[lower] ?? [lower]; -} diff --git a/src/ui/initUserInterface.js b/src/ui/initUserInterface.js new file mode 100644 index 0000000..8dd1691 --- /dev/null +++ b/src/ui/initUserInterface.js @@ -0,0 +1,106 @@ +import { getPlatformDisplay } from '../utils/defineRibbonVals.js'; + +/** + * Initializes UI elements from configuration values. + * This sets up the header, logo, repo ribbon, and dynamic styles. + * @param {Object} config - The loaded configuration object. + */ +export const initializeUIFromConfig = (config) => { + // Apply CSS custom properties and document metadata + document.title = config.CATALOG_TITLE || `${config.ORG_NAME} Catalog`; + document.documentElement.style.setProperty('--color-primary', config.COLORS?.primary || '#92991c'); + document.documentElement.style.setProperty('--color-secondary', config.COLORS?.secondary || '#5d8095'); + document.documentElement.style.setProperty('--color-accent', config.COLORS?.accent || '#0097b2'); + document.documentElement.style.setProperty('--color-accent-dark', config.COLORS?.accentDark || '#4fd1eb'); + document.documentElement.style.setProperty('--color-tag', config.COLORS?.tag || '#9bcb5e'); + const fontFamily = config.FONT_FAMILY || 'Inter'; + document.documentElement.style.setProperty('--font-family', fontFamily.includes(' ') ? `"${fontFamily}"` : fontFamily); + + // Update Google Fonts link + if (config.FONT_FAMILY) { + const fontFamily = config.FONT_FAMILY.replace(/\s+/g, '+'); + const fontLink = document.getElementById('font-link'); + if (fontLink) fontLink.href = `https://fonts.googleapis.com/css2?family=${fontFamily}:wght@400;500;600;700&display=swap`; + } + + // Set favicon + const faviconLink = document.getElementById('favicon-link'); + if (faviconLink && config.FAVICON_URL) faviconLink.href = config.FAVICON_URL; + + // Set header logo + const logoImg = document.getElementById('logo-img'); + if (logoImg) { + logoImg.src = config.LOGO_URL; + logoImg.alt = config.ORG_NAME + ' Logo'; + + logoImg.onload = () => { + logoImg.classList.remove('opacity-0'); + }; + } + + // Set header title and description + const headerTitle = document.getElementById('header-title'); + if (headerTitle) { + headerTitle.textContent = config.CATALOG_TITLE || `${config.ORG_NAME} Catalog`; + headerTitle.style.color = config.COLORS.primary; + } + + const headerDesc = document.getElementById('header-description'); + if (headerDesc) { + headerDesc.textContent = config.CATALOG_DESCRIPTION; + } + + // Set Code Repo ribbon link, SVG path, display name, and colors + const repoRibbon = document.getElementById('repo-ribbon'); + const platformDisplay = getPlatformDisplay(config.PLATFORM); + if (repoRibbon && platformDisplay) { + repoRibbon.href = `${platformDisplay.ribbonUrl}${config.ORGANIZATION_NAME}/${config.CATALOG_REPO_NAME}`; + const pathElement = document.getElementById('repo-ribbon-icon'); + pathElement.setAttribute('d', platformDisplay.path); + const platformDisplayName = document.getElementById('platform-display-name'); + platformDisplayName.textContent = platformDisplay.displayName || config.PLATFORM; + repoRibbon.style.backgroundColor = config.COLORS.secondary; + repoRibbon.style.setProperty('--hover-color', config.COLORS.primary); + repoRibbon.addEventListener('mouseenter', function () { + this.style.backgroundColor = config.COLORS.primary; + }); + repoRibbon.addEventListener('mouseleave', function () { + this.style.backgroundColor = config.COLORS.secondary; + }); + } + + // Set focus ring colors for form inputs and link hover colors + const style = document.createElement('style'); + style.textContent = ` + .focus\\:ring-2:focus { --tw-ring-color: var(--color-accent) !important; } + .item-link:hover { color: var(--color-accent) !important; } + .dark .item-link:hover { color: var(--color-accent-dark) !important; } + `; + document.head.appendChild(style); +}; + +// +// THEME TOGGLE LOGIC +// Establishes a button to toggle between light and dark themes, storing the preference in localStorage. +export const setThemeToggle = () => { + const themeToggleBtn = document.getElementById('themeToggleBtn'); + + if ( + localStorage.theme === 'dark' || + (!('theme' in localStorage) && window.matchMedia('(prefers-color-scheme: dark)').matches) + ) { + document.documentElement.classList.add('dark'); + } else { + document.documentElement.classList.remove('dark'); + } + + themeToggleBtn.addEventListener('click', () => { + if (document.documentElement.classList.contains('dark')) { + document.documentElement.classList.remove('dark'); + localStorage.theme = 'light'; + } else { + document.documentElement.classList.add('dark'); + localStorage.theme = 'dark'; + } + }); +}; diff --git a/src/ui/render.js b/src/ui/render.js new file mode 100644 index 0000000..754280f --- /dev/null +++ b/src/ui/render.js @@ -0,0 +1,164 @@ +/** + * This module contains functions for presentation of code repositories, datasets, models, or spaces in HTML. + * It includes functions for escaping HTML, adding word break opportunities, and rendering item cards. + */ + +/** + * Escapes HTML special characters in a string to prevent XSS. + * @param {string} str - The input string. + * @returns {string} The escaped string. + */ +const escapeHTML = (str) => { + if (!str) return ""; + return str.replace(/[&<>"']/g, (m) => ({ + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''' + }[m])); +}; + +/** + * Adds word break opportunities after underscores to allow proper text wrapping. + * @param {string} str - The input string. + * @returns {string} The string with tags inserted after underscores. + */ +const addWordBreakOpportunities = (str) => { + if (!str) return ""; + // Replace underscores with underscore + word break opportunity + return str.replace(/_/g, '_'); +}; + +/** + * Renders a single item card (code, dataset, model, or space) to HTML. + * @param {Object} item - The item object to render. + * @returns {string} The HTML string for the item card. + */ +const renderHubItemCard = (item) => { + const lastUpdatedDate = new Date(item.lastModified).toLocaleDateString(); + const tagsHtml = (item.displayTags || item.rawTags || []).map(tag => + `${escapeHTML(tag)}` + ).join(''); + + // Use pretty_name for the heading, with a fallback + // HF API keys for CardData: https://huggingface.co/docs/huggingface_hub/main/en/package_reference/cards#huggingface_hub.CardData + // datasets have pretty_name, models have model_name, spaces have title + const prettyName = item.cardData?.pretty_name || item.cardData?.model_name || item.cardData?.title || item.id.split('/')[1]; + + // Use the description from cardData, with fallbacks + const displayDescription = item.cardData?.description || item.cardData?.model_description || item.description || 'No description provided.'; + + // Construct the correct URL based on the repository type + let itemUrl; + + switch (item.repoType) { + case "code": + itemUrl = item.html_url; + break; + case "datasets": + itemUrl = `https://huggingface.co/datasets/${item.id}`; + break; + case "spaces": + itemUrl = `https://huggingface.co/spaces/${item.id}`; + break; + case "models": + itemUrl = `https://huggingface.co/${item.id}`; + break; + default: + // fallback for "all" + itemUrl = `https://huggingface.co/${item.id}`; + break; + } + + // stars for code repos + const badgeHtml = (() => { + if (item.isNew) { + return ` + New! + `; + } + + if (typeof item.cardData?.stars === "number" && item.cardData.stars > 0) { + return ` + ⭐ ${item.cardData.stars} + `; + } + + if (typeof item.likes === "number" && item.likes > 0) { + return ` + + ❤️ ${item.likes} + `; + } + return ""; + })(); + + const escapedTitle = escapeHTML(prettyName); + const displayTitle = addWordBreakOpportunities(escapedTitle); + + return ` +
+
+
+

+ + ${displayTitle} + +

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

+ ${escapeHTML(displayDescription)} +

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

Error loading items.

+

${escapeHTML(message)}

+
`; +}; diff --git a/src/ui/urlManager.js b/src/ui/urlManager.js new file mode 100644 index 0000000..0c38335 --- /dev/null +++ b/src/ui/urlManager.js @@ -0,0 +1,85 @@ +/** + * This module manages URL parameters for the catalog application: parsing, updating, and reflecting the + * current filter state in the URL for shareable searches. + */ + +/** + * Parses URL parameters from both query string (?key=value) and hash (#key=value). + * Query parameters take precedence over hash parameters. + * @returns {Object} An object containing the parsed parameters. + */ +export const parseUrlParams = () => { + const params = {}; + + // Parse query string parameters (e.g., ?type=datasets&q=fish) + const queryString = window.location.search; + const urlParams = new URLSearchParams(queryString); + + // Parse hash parameters (e.g., #type=datasets&q=fish) + const hash = window.location.hash.slice(1); // Remove the leading '#' + const hashParams = new URLSearchParams(hash); + + // Hash parameters first (lower precedence) + for (const [key, value] of hashParams) { + params[key] = value; + } + + // Query parameters override hash parameters (higher precedence) + for (const [key, value] of urlParams) { + params[key] = value; + } + + return params; +}; + +/** + * Updates the URL hash with the current filter state without triggering a page reload. + * @param {Object} state - The current state object with type, q, sort, tag, archived properties. + */ +export const updateUrlParams = (state) => { + const params = new URLSearchParams(); + + // Only add non-default values to the URL + if (state.type && state.type !== 'all') { + params.set('type', state.type); + } + if (state.q && state.q.trim() !== '') { + params.set('q', state.q); + } + if (state.sort && state.sort !== 'lastModified') { + params.set('sort', state.sort); + } + if (state.tag && state.tag !== '') { + params.set('tag', state.tag); + } + if (state.archived && state.archived !== 'active') { + params.set('archived', state.archived); + } + + const paramString = params.toString(); + const newHash = paramString ? `#${paramString}` : ''; + + // Build the new URL preserving the pathname and any query parameters when clearing hash + const baseUrl = window.location.pathname + window.location.search; + const newUrl = newHash ? baseUrl + newHash : baseUrl; + + // Update the URL without triggering a page reload + const currentUrl = window.location.pathname + window.location.search + window.location.hash; + if (currentUrl !== newUrl) { + history.replaceState(null, '', newUrl); + } +}; + +/** + * Gets the current filter state from form elements. + * @returns {Object} The current state object. + */ +export const getCurrentState = () => { + return { + type: document.getElementById('repoType')?.value || 'all', + q: document.getElementById('searchInput')?.value || '', + sort: document.getElementById('sortBy')?.value || 'lastModified', + tag: document.getElementById('tagFilter')?.value || '', + archived: document.getElementById('archiveFilter')?.value || 'active' + }; +}; diff --git a/src/defineApiUrls.js b/src/utils/defineApiUrls.js similarity index 100% rename from src/defineApiUrls.js rename to src/utils/defineApiUrls.js diff --git a/src/defineRibbonVals.js b/src/utils/defineRibbonVals.js similarity index 100% rename from src/defineRibbonVals.js rename to src/utils/defineRibbonVals.js diff --git a/src/filterAndSort.js b/src/utils/filterAndSort.js similarity index 100% rename from src/filterAndSort.js rename to src/utils/filterAndSort.js diff --git a/src/filterNewAdditionalEntries.js b/src/utils/filterNewAdditionalEntries.js similarity index 100% rename from src/filterNewAdditionalEntries.js rename to src/utils/filterNewAdditionalEntries.js diff --git a/src/utils/normalizeTag.js b/src/utils/normalizeTag.js new file mode 100644 index 0000000..6b89177 --- /dev/null +++ b/src/utils/normalizeTag.js @@ -0,0 +1,64 @@ +/** + * Tag (keyword) processing, including normalization and tag-group lookup. + * These functions are used to standardize tags across different repositories and platforms. + */ + +/** + * Builds a reverse lookup map from TAG_GROUPS (defined in tag-groups.js): raw tag → [canonical tags] + * A raw tag may appear in multiple groups, so the value is an array. + * @returns {Object} The reverse lookup map. + */ +export const buildTagLookup = () => { + const lookup = Object.create(null); + if (typeof TAG_GROUPS !== 'undefined') { + for (const [canonical, aliases] of Object.entries(TAG_GROUPS)) { + for (const alias of aliases) { + const key = alias.toLowerCase(); + if (lookup[key]) { + lookup[key].push(canonical); + } else { + lookup[key] = [canonical]; + } + } + } + } + return lookup; +}; + +const tagLookup = buildTagLookup(); + +/** + * Normalizes a raw tag string for the tag selection dropdown. + * - Returns null for Hugging Face system metadata tags (tags containing a colon). + * - Maps known aliases to their canonical tag(s) via tagLookup. + * - Falls back to the lowercased original if no mapping exists. + * @param {string} tag + * @param {Object} [lookup=tagLookup] - Optional reverse lookup map (alias → canonical tags). + * Defaults to the module's prebuilt lookup from TAG_GROUPS. Pass a custom lookup in tests. + * @returns {string[]|null} + */ +export function normalizeTag(tag, lookup = tagLookup) { + const lower = String(tag).toLowerCase(); + // OPTION LINE -- REMOVE IF UNWANTED + // Removes Hugging Face auto-generated system tags (e.g. "license:mit", "format:parquet") from dropdown filter. + // These are identified by the presence of a colon. To include auto-generated tags in the + // catalog, remove the following line and `.filter` line from the filterDisplayTags function. + if (lower.includes(':')) return null; + return lookup[lower] ?? [lower]; +} + +/** + * Sorts and filters tags for display in the repo cards. + * Currently excludes tags containing a colon (Hugging Face system metadata tags). + * @param {string[]} rawTags - Array of tags to sort and filter. + * @returns {string[]} Filtered and sorted array of tags for display. + */ +export function filterDisplayTags(rawTags) { + let displayTags = rawTags.sort() || []; + // OPTION LINE -- REMOVE IF UNWANTED + // Removes Hugging Face auto-generated system tags (e.g. "license:mit", "format:parquet") from display. + // These are identified by the presence of a colon. To include auto-generated tags in the repo cards, + // remove the following line. + displayTags = displayTags.filter(t => !t.includes(':')); + return displayTags; +} diff --git a/src/validateConfig.js b/src/validateConfig.js index 7cac8f3..d903afe 100644 --- a/src/validateConfig.js +++ b/src/validateConfig.js @@ -14,15 +14,35 @@ export function validateConfig(config) { return errors; } - if (!config.ORGANIZATION_NAME) errors.push('ORGANIZATION_NAME'); + /** Validate organization names, existence and allowed characters */ + const ghOrgRegex = /^[a-zA-Z0-9-]+$/; + const orgName = config.ORGANIZATION_NAME; + if (typeof orgName !== 'string' || !orgName.trim()) { + errors.push('ORGANIZATION_NAME'); + } else if (!ghOrgRegex.test(orgName)) { + errors.push(`ORGANIZATION_NAME (${orgName}) is invalid, only letters, numbers, and hyphens are allowed`); + } + const hfOrgRegex = /^[a-zA-Z0-9_\-]+$/; + const hfOrgName = config.HF_ORGANIZATION_NAME; + if (typeof hfOrgName !== 'string' || !hfOrgName.trim()) { + errors.push('HF_ORGANIZATION_NAME'); + } else if (!hfOrgRegex.test(hfOrgName)) { + errors.push(`HF_ORGANIZATION_NAME (${hfOrgName}) is invalid, only letters, numbers, hyphens, and underscores are allowed`); + } if (!config.ORG_NAME) errors.push('ORG_NAME'); if (!config.CATALOG_REPO_NAME) errors.push('CATALOG_REPO_NAME'); - if (!config.PLATFORM) errors.push('PLATFORM'); - /** Update to include 'codeberg' and 'gitlab' once supported */ + + /** Update to include 'codeberg' and 'gitlab' once supported + * PLATFORM will be parsed without whitespace, but must be string to avoid undefined errors + */ const supportedPlatforms = ['github']; - if (config.PLATFORM && !supportedPlatforms.includes(config.PLATFORM.toLowerCase())) { + const platform = config.PLATFORM; + if (!platform || typeof platform !== 'string') { + errors.push('PLATFORM'); + } else if (!supportedPlatforms.includes(platform.toLowerCase())) { errors.push(`PLATFORM must be one of: ${supportedPlatforms.join(', ')}`); } + if (!config.API_BASE_URL) errors.push('API_BASE_URL'); if (config.REFRESH_INTERVAL_DAYS == null) errors.push('REFRESH_INTERVAL_DAYS'); diff --git a/tests/api/fetchCodeRepos.test.js b/tests/api/fetchCodeRepos.test.js new file mode 100644 index 0000000..98c594b --- /dev/null +++ b/tests/api/fetchCodeRepos.test.js @@ -0,0 +1,310 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { fetchCodeRepos } from '../../src/api/fetchCodeRepos.js'; +import { handleError } from '../../src/ui/render.js'; + +// Mock the internal dependencies +vi.mock('../../src/utils/normalizeTag.js', () => ({ + normalizeTag: vi.fn((tag) => [tag.toLowerCase()]), + filterDisplayTags: vi.fn((rawTags) => (rawTags || []).filter(t => !t.includes(':'))) +})); +vi.mock('../../src/ui/render.js', () => ({ + handleError: vi.fn() +})); +vi.mock('../../src/utils/defineRibbonVals.js', () => ({ + getPlatformDisplay: vi.fn(() => 'GitHub') +})); + +describe('fetchCodeRepos', () => { + const originalFetch = global.fetch; + + beforeEach(() => { + global.fetch = vi.fn(); + }); + afterEach(() => { + global.fetch = originalFetch; + vi.clearAllMocks(); + }); + + const orgApiUrl = 'https://api.github.com/orgs/test-org/repos'; + const repoApiUrl = 'https://api.github.com/repos/'; + const refreshIntervalDays = 30; + const releasesMap = {}; + + // Generate reliable relative timestamps so tests don't break as time passes + const now = new Date(); + const recentDateISO = new Date(now.getTime() - 2 * 24 * 60 * 60 * 1000).toISOString(); // 2 days ago + const oldDateISO = new Date(now.getTime() - 45 * 24 * 60 * 60 * 1000).toISOString(); // 45 days ago + const oldestDateISO = new Date(now.getTime() - 90 * 24 * 60 * 60 * 1000).toISOString(); // 90 days ago + + it('maps raw repo data and attaches release data from releasesMap', async () => { + // Mock a single page GitHub response + global.fetch.mockResolvedValueOnce({ + ok: true, + headers: { get: () => null }, // No 'link' header means no pagination + json: () => Promise.resolve([{ + full_name: 'test-org/code-repo', + name: 'code-repo', + updated_at: now.toISOString(), + created_at: recentDateISO, + topics: ['python'], + stargazers_count: 42, + }]) + }); + + // Mock releases map generated by scripts/fetch-releases.js + const mockReleasesMap = { + 'test-org/code-repo': { tag: 'v1.0', url: 'http://...', isNew: true } + }; + + const items = await fetchCodeRepos( + 'github', + [], // No additional repos + orgApiUrl, + repoApiUrl, + refreshIntervalDays, + mockReleasesMap + ); + + expect(items).toHaveLength(1); + expect(items[0].repoType).toBe('code'); + expect(items[0].hasNewRelease).toBe(true); + expect(items[0].latestReleaseTag).toBe('v1.0'); + expect(items[0].tags).toContain('python'); + expect(items[0].cardData.pretty_name).toBe('code-repo'); + expect(items[0].cardData.stars).toBe(42); + }); + + // Pagination test: simulate multiple pages of GitHub API results using the Link header + it('handles multi-page pagination', async () => { + global.fetch.mockImplementation((url) => { + if (url === orgApiUrl) { + // Page 1 setup with Link header pointing to Page 2 + return Promise.resolve({ + ok: true, + headers: { + get: (name) => name.toLowerCase() === 'link' + ? `<${orgApiUrl}?page=2>; rel="next"` + : null + }, + json: () => Promise.resolve([{ + full_name: 'test-org/repo-page1', + name: 'repo-page1', + created_at: recentDateISO, + updated_at: recentDateISO + }]) + }); + } else if (url === `${orgApiUrl}?page=2`) { + // Page 2 setup with no Link header (breaks loop) + return Promise.resolve({ + ok: true, + headers: { get: () => null }, + json: () => Promise.resolve([{ + full_name: 'test-org/repo-page2', + name: 'repo-page2', + created_at: recentDateISO, + updated_at: recentDateISO }]) + }); + } + }); + + const items = await fetchCodeRepos( + 'github', + [], + orgApiUrl, + repoApiUrl, + refreshIntervalDays, + releasesMap); + + expect(global.fetch).toHaveBeenCalledTimes(2); + expect(global.fetch).toHaveBeenNthCalledWith(1, orgApiUrl); + expect(global.fetch).toHaveBeenNthCalledWith(2, `${orgApiUrl}?page=2`); + expect(items).toHaveLength(2); + expect(items.map(i => i.id)).toEqual(['test-org/repo-page1', 'test-org/repo-page2']); + expect(items[0].cardData.pretty_name).toBe('repo-page1'); + expect(items[1].cardData.pretty_name).toBe('repo-page2'); + }); + + // Test additional repos fetching and merging with org repos + it('fetches additional org repos once and fetches external repos', async () => { + // Base organization discovery lists 'test-org/internal-additional' + const mockOrgRepo = { + full_name: 'test-org/internal-additional', + name: 'internal-additional', + created_at: recentDateISO, + updated_at: recentDateISO + }; + + const mockExternalRepo = { + full_name: 'external-org/external-additional', + name: 'external-additional', + created_at: recentDateISO, + updated_at: recentDateISO + }; + + global.fetch.mockImplementation((url) => { + if (url === orgApiUrl) { + return Promise.resolve({ + ok: true, + headers: { get: () => null }, + json: () => Promise.resolve([mockOrgRepo]) + }); + } + if (url === `${repoApiUrl}external-org/external-additional`) { + return Promise.resolve({ + ok: true, + headers: { get: () => null }, + json: () => Promise.resolve(mockExternalRepo) + }); + } + }); + + const additionalRepos = [ + 'test-org/internal-additional', + 'external-org/external-additional' + ]; + + const items = await fetchCodeRepos( + 'github', + additionalRepos, + orgApiUrl, + repoApiUrl, + refreshIntervalDays, + releasesMap + ); + + /** Fetch should only be triggered twice: Once for org list, once for external repo. + * The internal-additional repo is found inside the cache, + * should not hit the network a second time. + */ + expect(global.fetch).toHaveBeenCalledTimes(2); + expect(global.fetch).not.toHaveBeenCalledWith( + `${repoApiUrl}test-org/internal-additional` + ); + + /** Assert deduplication: 'test-org/internal-additional' in both core lists, + * shouldn't show up twice in processed outputs. + */ + expect(items).toHaveLength(2); + expect(items.map(i => i.id)).toContain('test-org/internal-additional'); + expect(items.map(i => i.id)).toContain('external-org/external-additional'); + }); + + // Test that forks and .github repos are excluded from the final list + it('excludes repository targets designated as forks and .github', async () => { + global.fetch.mockResolvedValueOnce({ + ok: true, + headers: { get: () => null }, + json: () => Promise.resolve([ + { + full_name: 'test-org/valid-repo', + name: 'valid-repo', + fork: false, + created_at: recentDateISO, + updated_at: recentDateISO + }, + { + full_name: 'test-org/forked-repo', + name: 'forked-repo', + fork: true, + created_at: recentDateISO, + updated_at: recentDateISO + }, + { + full_name: 'test-org/forked-in-list', + name: 'forked-in-list', + fork: true, + created_at: recentDateISO, + updated_at: recentDateISO + }, + { + full_name: 'test-org/.github', + name: '.github', + fork: false, + created_at: recentDateISO, + updated_at: recentDateISO + } + ]) + }); + + const additionalRepos = [ + 'test-org/forked-in-list' // This is a fork, but listed to include + ]; + + const items = await fetchCodeRepos( + 'github', + additionalRepos, + orgApiUrl, + repoApiUrl, + refreshIntervalDays, + releasesMap); + + expect(items).toHaveLength(2); // Should only include valid-repo and forked-in-list + expect(items.map(i => i.id)).toContain('test-org/valid-repo'); + expect(items.map(i => i.id)).toContain('test-org/forked-in-list'); + expect(items.map(i => i.id)).not.toContain('test-org/forked-repo'); + expect(items.map(i => i.id)).not.toContain('test-org/.github'); + }); + + // Test that isNew flag is set correctly based on creation date and refresh interval + it('flags items as new based on evaluation thresholds', async () => { + global.fetch.mockResolvedValueOnce({ + ok: true, + headers: { get: () => null }, + json: () => Promise.resolve([ + { + full_name: 'test-org/new-repo', + name: 'new-repo', + created_at: recentDateISO, + updated_at: now.toISOString() + }, + { + full_name: 'test-org/old-repo', + name: 'old-repo', + created_at: oldestDateISO, + updated_at: oldDateISO + } + ]) + }); + + const items = await fetchCodeRepos( + 'github', + [], + orgApiUrl, + repoApiUrl, + refreshIntervalDays, + releasesMap + ); + + const newRepo = items.find(i => i.id === 'test-org/new-repo'); + const oldRepo = items.find(i => i.id === 'test-org/old-repo'); + + expect(newRepo.isNew).toBe(true); + expect(oldRepo.isNew).toBe(false); + }); + + // Test that network errors are handled gracefully and trigger UI error handling + it('handles network errors gracefully', async () => { + // Force the main fetch to fail + global.fetch.mockResolvedValueOnce({ + ok: false, + status: 404, + headers: { get: () => null } + }); + + const items = await fetchCodeRepos( + 'github', + [], + orgApiUrl, + repoApiUrl, + refreshIntervalDays, + releasesMap + ); + + expect(handleError).toHaveBeenCalledWith( + expect.any(Error), + expect.stringContaining('Failed to fetch code from github') + ); + expect(items).toEqual([]); + }); + +}); diff --git a/tests/api/fetchHfRepos.test.js b/tests/api/fetchHfRepos.test.js new file mode 100644 index 0000000..ef6248a --- /dev/null +++ b/tests/api/fetchHfRepos.test.js @@ -0,0 +1,341 @@ +// tests/fetchHfRepos.test.js +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { fetchHfRepos } from '../../src/api/fetchHfRepos.js'; +import { handleError } from '../../src/ui/render.js'; + +// Mock the internal dependencies +vi.mock('../../src/utils/normalizeTag.js', () => ({ + normalizeTag: vi.fn((tag) => [tag.toLowerCase()]), + filterDisplayTags: vi.fn((rawTags) => (rawTags || []).filter(t => !t.includes(':'))) +})); + +vi.mock('../../src/ui/render.js', () => ({ + handleError: vi.fn() +})); + +vi.mock('../../src/utils/filterNewAdditionalEntries.js', () => ({ + // Pass the additional entries straight through + filterNewAdditionalEntries: vi.fn((existingIds, additional) => + additional.filter(entry => !existingIds.has(entry.repo))) +})); + +describe('fetchHfRepos', () => { + const originalFetch = global.fetch; + + beforeEach(() => { + // Reset the fetch mock before each test + global.fetch = vi.fn(); + }); + + afterEach(() => { + global.fetch = originalFetch; + vi.clearAllMocks(); + }); + + const apiBaseUrl = 'https://huggingface.co/api/'; + const hfOrgName = 'test-org'; + const refreshIntervalDays = 30; + + // Generate reliable relative timestamps so tests don't break as time passes + const now = new Date(); + const recentDateISO = new Date(now.getTime() - 2 * 24 * 60 * 60 * 1000).toISOString(); // 2 days ago + const oldDateISO = new Date(now.getTime() - 45 * 24 * 60 * 60 * 1000).toISOString(); // 45 days ago + const oldestDateISO = new Date(now.getTime() - 90 * 24 * 60 * 60 * 1000).toISOString(); // 90 days ago + + // Test fetching datasets and checking the "new" flag based on creation date + it('fetches datasets and flags new repositories as expected', async () => { + global.fetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve([ + { + id: 'test-org/new-dataset', + createdAt: recentDateISO, + lastModified: recentDateISO, + likes: 10, + tags: ['nlp'] + }, + { + id: 'test-org/old-dataset', + createdAt: oldestDateISO, + lastModified: oldDateISO, + likes: 5, + tags: ['vision'] + } + ]) + }); + + const items = await fetchHfRepos( + 'datasets', + [], + apiBaseUrl, + hfOrgName, + refreshIntervalDays + ); + + expect(items).toHaveLength(2); + // new dataset expected values + expect(items[0].id).toBe('test-org/new-dataset'); + expect(items[0].repoType).toBe('datasets'); + expect(items[0].likes).toBe(10); + expect(items[0].tags).toContain('nlp'); + expect(items[0].isNew).toBe(true); + // old dataset expected values + expect(items[1].id).toBe('test-org/old-dataset'); + expect(items[1].repoType).toBe('datasets'); + expect(items[1].likes).toBe(5); + expect(items[1].tags).toContain('vision'); + expect(items[1].isNew).toBe(false); + }); + + it('fetches datasets as expected without secondary detail fetch', async () => { + // Setup mock response for the main org datasets + global.fetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve([{ + id: 'test-org/dataset-1', + createdAt: recentDateISO, // 1 month ago + lastModified: now.toISOString(), + tags: ['nlp', 'task:text-classification'], + cardData: + { + pretty_name: 'Dataset 1', + description: 'Test dataset' + } + }]) + }); + + const items = await fetchHfRepos( + 'datasets', + [], // No additional repos + apiBaseUrl, + hfOrgName, + refreshIntervalDays + ); + + expect(global.fetch).toHaveBeenCalledTimes(1); + expect(global.fetch).toHaveBeenCalledWith(`${apiBaseUrl}datasets?author=${hfOrgName}&full=true`); + expect(items).toHaveLength(1); + expect(items[0].repoType).toBe('datasets'); + expect(items[0].tags).toContain('nlp'); + expect(items[0].cardData.pretty_name).toBe('Dataset 1'); + expect(items[0].cardData.description).toBe('Test dataset'); + + // Assert dates are correctly converted to Date objects + expect(items[0].createdAt).toBeInstanceOf(Date); + expect(items[0].lastModified).toBeInstanceOf(Date); + }); + + it('makes secondary fetch calls when fetching models to get full details', async () => { + // We need a custom mock implementation because models trigger sequential fetch calls + global.fetch.mockImplementation((url) => { + if (url.includes('models?author=')) { + // Return the initial summary list + return Promise.resolve({ + ok: true, + json: () => Promise.resolve([{ id: 'test-org/model-1' }]) + }); + } + if (url.includes('models/test-org/model-1')) { + // Return the detailed model data + return Promise.resolve({ + ok: true, + json: () => Promise.resolve({ + id: 'test-org/model-1', + createdAt: now.toISOString(), // Simulating a brand new model + lastModified: now.toISOString(), + cardData: + { + model_name: 'Model 1', + model_description: 'Test model', + tags: ['vision'] + } + }) + }); + } + }); + + const items = await fetchHfRepos( + 'models', + [], + apiBaseUrl, + hfOrgName, + refreshIntervalDays + ); + + // It should have called the main endpoint and the specific model endpoint + expect(global.fetch).toHaveBeenCalledTimes(2); + expect(global.fetch).toHaveBeenCalledWith(`${apiBaseUrl}models/test-org/model-1`); + + expect(items).toHaveLength(1); + expect(items[0].isNew).toBe(true); + expect(items[0].tags).toContain('vision'); + expect(items[0].cardData.model_name).toBe('Model 1'); + expect(items[0].cardData.model_description).toBe('Test model'); + }); + + // Test that the function handles partial failures gracefully + it('survives partial failures if one model secondary fetch fails but another succeeds', async () => { + global.fetch.mockImplementation((url) => { + if (url.includes('models?author=')) { + return Promise.resolve({ + ok: true, + json: () => Promise.resolve([ + { id: 'test-org/good-model' }, + { id: 'test-org/broken-model' } + ]) + }); + } + if (url.includes('models/test-org/good-model')) { + return Promise.resolve({ + ok: true, + json: () => Promise.resolve({ + id: 'test-org/good-model', + createdAt: recentDateISO, + lastModified: recentDateISO + }) + }); + } + if (url.includes('models/test-org/broken-model')) { + // Simulate a broken/missing resource detail + return Promise.resolve({ ok: false, status: 404 }); + } + }); + + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + const items = await fetchHfRepos( + 'models', + [], + apiBaseUrl, + hfOrgName, + refreshIntervalDays + ); + + // The broken model should be cleanly filtered out by detailedItems.filter(Boolean) + expect(items).toHaveLength(1); + expect(items[0].id).toBe('test-org/good-model'); + expect(items[0].isNew).toBe(true); + expect(items[0].repoType).toBe('models'); + + // Ensure the error was logged for the broken model + expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining( + 'Failed to fetch details for test-org/broken-model' + )); + + consoleSpy.mockRestore(); + }); + + // Test that additional repos are fetched and merged correctly + it('fetches additional Hugging Face repos and merges them', async () => { + const additionalHfRepos = [ + { type: 'spaces', repo: 'external-user/cool-space' }, + { type: 'spaces', repo: 'test-org/space-1' } // Already in org, should be ignored + ]; + + global.fetch.mockImplementation((url) => { + if (url.includes('spaces?author=')) { + return Promise.resolve({ + ok: true, + json: () => Promise.resolve([{ + id: 'test-org/space-1', + createdAt: recentDateISO, + lastModified: recentDateISO + }]) + }); + } + if (url.includes('spaces/external-user/cool-space')) { + return Promise.resolve({ + ok: true, + json: () => Promise.resolve({ + id: 'external-user/cool-space', + createdAt: oldDateISO, + lastModified: recentDateISO + }) + }); + } + }); + + const items = await fetchHfRepos( + 'spaces', + additionalHfRepos, + apiBaseUrl, + hfOrgName, + refreshIntervalDays + ); + + // 1 for org spaces, 1 for additional space, duplicate not fetched again + expect(items).toHaveLength(2); + expect(global.fetch).toHaveBeenCalledTimes(2); + expect(global.fetch).toHaveBeenCalledWith(`${apiBaseUrl}spaces/external-user/cool-space`); + expect(global.fetch).toHaveBeenCalledWith(`${apiBaseUrl}spaces?author=${hfOrgName}&full=true`); + + expect(items[0].id).toBe('test-org/space-1'); + expect(items[0].repoType).toBe('spaces'); + expect(items[0].isNew).toBe(true); + + expect(items[1].id).toBe('external-user/cool-space'); + expect(items[1].repoType).toBe('spaces'); + expect(items[1].isNew).toBe(false); + }); + + // Test that network errors in fetching additional repos are handled gracefully + it('logs warnings and ignores external additional repo if its fetch throws a network error', async () => { + const additionalHfRepos = [{ type: 'datasets', repo: 'external-user/broken-additional' }]; + + global.fetch.mockImplementation((url) => { + if (url.includes('datasets?author=')) { + return Promise.resolve({ + ok: true, + json: () => Promise.resolve([{ + id: 'test-org/dataset-1', + createdAt: recentDateISO, + lastModified: recentDateISO + }]) + }); + } + if (url.includes('datasets/external-user/broken-additional')) { + // Hard crash fetch + return Promise.reject(new Error('Network disconnected')); + } + }); + + const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + const items = await fetchHfRepos( + 'datasets', + additionalHfRepos, + apiBaseUrl, + hfOrgName, + refreshIntervalDays + ); + + // System should catch the error safely, remain un-crashed, and return just the internal dataset + expect(items).toHaveLength(1); + expect(items[0].id).toBe('test-org/dataset-1'); + expect(consoleWarnSpy).toHaveBeenCalledWith( + expect.stringContaining('Network error fetching additional HF repo "external-user/broken-additional":'), + expect.any(Error) + ); + + consoleWarnSpy.mockRestore(); + }); + + it('handles network errors gracefully', async () => { + // Force the main fetch to fail + global.fetch.mockResolvedValueOnce({ + ok: false, + status: 500 + }); + + const items = await fetchHfRepos( + 'datasets', + [], + apiBaseUrl, + hfOrgName, + refreshIntervalDays + ); + + expect(items).toEqual([]); + expect(handleError).toHaveBeenCalled(); + }); +}); diff --git a/tests/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..089e2e6 100644 --- a/tests/validateConfig.test.js +++ b/tests/validateConfig.test.js @@ -3,6 +3,7 @@ import { validateConfig } from '../src/validateConfig.js'; const VALID_CONFIG = { ORGANIZATION_NAME: 'imageomics', + HF_ORGANIZATION_NAME: 'imageomics', ORG_NAME: 'Imageomics', CATALOG_REPO_NAME: 'catalog', PLATFORM: 'github', @@ -53,6 +54,27 @@ describe('validateConfig', () => { expect(validateConfig({ ...VALID_CONFIG, ORGANIZATION_NAME: '' })).toContain('ORGANIZATION_NAME'); }); + it('errors when ORGANIZATION_NAME is not in accepted format', () => { + expect( + validateConfig({ ...VALID_CONFIG, ORGANIZATION_NAME: 'abc center' }) + ).toContain('ORGANIZATION_NAME (abc center) is invalid, only letters, numbers, and hyphens are allowed'); + }); + + it('errors when HF_ORGANIZATION_NAME is missing', () => { + const { HF_ORGANIZATION_NAME: _, ...config } = VALID_CONFIG; + expect(validateConfig(config)).toContain('HF_ORGANIZATION_NAME'); + }); + + it('errors when HF_ORGANIZATION_NAME is empty string', () => { + expect(validateConfig({ ...VALID_CONFIG, HF_ORGANIZATION_NAME: '' })).toContain('HF_ORGANIZATION_NAME'); + }); + + it('errors when HF_ORGANIZATION_NAME is not in accepted format', () => { + expect( + validateConfig({ ...VALID_CONFIG, HF_ORGANIZATION_NAME: 'abc center' }) + ).toContain('HF_ORGANIZATION_NAME (abc center) is invalid, only letters, numbers, hyphens, and underscores are allowed'); + }); + it('errors when ORG_NAME is missing', () => { const { ORG_NAME: _, ...config } = VALID_CONFIG; expect(validateConfig(config)).toContain('ORG_NAME'); @@ -75,6 +97,10 @@ describe('validateConfig', () => { const { PLATFORM: _, ...config } = VALID_CONFIG; expect(validateConfig(config)).toContain('PLATFORM'); }); + + it('errors when PLATFORM is not a string', () => { + expect(validateConfig({ ...VALID_CONFIG, PLATFORM: 123 })).toContain('PLATFORM'); + }); it('errors when PLATFORM is unrecognized', () => { const errors = validateConfig({ ...VALID_CONFIG, PLATFORM: 'blah' }); @@ -141,6 +167,7 @@ describe('validateConfig', () => { }; const errors = validateConfig(config); expect(errors).toContain('ORGANIZATION_NAME'); + expect(errors).toContain('HF_ORGANIZATION_NAME'); expect(errors).toContain('ORG_NAME'); expect(errors).toContain('CATALOG_REPO_NAME'); expect(errors).toContain('PLATFORM'); diff --git a/vite.config.js b/vite.config.js index c216b73..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]