From 44bad342c44efa54aaace90e86db38c519cc6417 Mon Sep 17 00:00:00 2001 From: Elizabeth Campolongo <38985481+egrace479@users.noreply.github.com> Date: Fri, 10 Jul 2026 17:48:32 -0400 Subject: [PATCH 1/6] Add Codeberg as a code platform option (#121) * Switch to local SVG use for code platform logo Codeberg logo is too complex for simple svg path definition * Check release not undefined for ribbon, as with stars and forks, there may not be a release * Generalize tests for code fetching to cycle platforms Add profile repo key, Codeberg uses .profile like GitHub has .github --- README.md | 12 +- docs/personalization.md | 8 +- docs/tag-grouping-process.md | 7 +- index.html | 5 +- public/config.yaml | 2 +- src/api/fetchCodeRepos.js | 27 +++- src/api/fetchStats.js | 4 +- src/assets/GitHub_Invertocat.svg | 10 ++ src/assets/codeberg-logo_icon_white.svg | 164 ++++++++++++++++++++++++ src/ui/initUserInterface.js | 4 +- src/utils/defineApiUrls.js | 16 +-- src/utils/defineRibbonVals.js | 30 ++--- src/validateConfig.js | 6 +- tests/api/fetchCodeRepos.test.js | 62 ++++++--- 14 files changed, 294 insertions(+), 63 deletions(-) create mode 100755 src/assets/GitHub_Invertocat.svg create mode 100644 src/assets/codeberg-logo_icon_white.svg diff --git a/README.md b/README.md index b10087a..f038f2a 100644 --- a/README.md +++ b/README.md @@ -8,13 +8,13 @@ For those interested in creating a similar catalog website or contributing to th The website is styled using the [tailwindcss](https://tailwindcss.com/) package. -* **Real-time Data Fetching:** Displays all public organization repositories, fetched through the GitHub and Hugging Face APIs. Includes semantically meaningful virtual markers: +* **Real-time Data Fetching:** Displays all public organization repositories, fetched through the code platform and Hugging Face APIs. Includes semantically meaningful virtual markers: * "New" badge highlights products created within the last 30 days; - * "🚀 version-tag" badge indicates a new release within the last 2 weeks for GitHub repos, and links to that release; - * Star (⭐️) or like (❤️) counts displayed for GitHub or Hugging Face repos, respectively; - * Archived badge flags GitHub repos no longer under active development (read-only). + * "🚀 version-tag" badge indicates a new release within the last 2 weeks for code repos, and links to that release; + * Star (⭐️) or like (❤️) counts displayed for code platform or Hugging Face repos, respectively; + * Archived badge flags code repos no longer under active development (read-only). * **Search Functionality:** Quickly find items by keyword. -* **Filtering:** Filter by repository type (Code, Datasets, Models, Spaces) and tags. Optionally include archived GitHub repos. +* **Filtering:** Filter by repository type (Code, Datasets, Models, Spaces) and tags. Optionally include archived code repos. * **Sorting:** Sort items by last updated, date created, stars/likes ascending or descending, or alphabetically. * **URL Parameter Support:** Persist and share search states via URL hash (`#type=datasets&q=fish`) or query parameters (`?type=datasets`). Supports `type`, `q` (search query), `sort`, and `tag` parameters. * **Responsive Design:** The layout is optimized for use on computers and mobile devices. @@ -30,7 +30,7 @@ The site runs based on four primary files: * `style.css`: Custom styling for the application, including color schemes, layout, and animations. Colors are set via CSS custom properties that are populated from `config.yaml`. * `main.js`: Application manager, handles config loading, event listeners, and coordinates UI updates based on API fetches for dynamic rendering of the catalog items. * `src/`: Modular application logic, organized by purpose: - * `api/`: Data fetching modules for external platforms (GitHub and Hugging Face). + * `api/`: Data fetching modules for external platforms (code and Hugging Face). * `ui/`: DOM manipulation, HTML templating, and URL/State routing. * `utils/`: Utility functions such as data filtering, sorting, and tag (keyword) normalization. diff --git a/docs/personalization.md b/docs/personalization.md index cc26212..d8a0756 100644 --- a/docs/personalization.md +++ b/docs/personalization.md @@ -4,7 +4,7 @@ Welcome to your new catalog repo! The primary way to personalize this catalog is ## Primary Configuration File -[**`public/config.yaml`**](../public/config.yaml): This is the main file to edit. It contains all configuration options (e.g., organization names, colors, branding, and API settings) with inline comments explaining each setting. Replace Imageomics-specific values with those appropriate for your organzation and catalog repository. +[**`public/config.yaml`**](../public/config.yaml): This is the main file to edit. It contains all configuration options (e.g., organization names, colors, branding, and API settings) with inline comments explaining each setting. Replace Imageomics-specific values with those appropriate for your organization and catalog repository. ### Organization & Repository Settings @@ -32,7 +32,7 @@ Welcome to your new catalog repo! The primary way to personalize this catalog is ### API & Behavior Settings - * `PLATFORM`: Coding platform used (default: 'github', Codeberg and GitLab support in development) + * `PLATFORM`: Coding platform used: 'github' or 'codeberg' (default: 'github', GitLab support in development). Please see [notes on platform setup](#non-github-code-platform-setup) if using for non-GitHub code repositories * `API_BASE_URL`: Hugging Face API base URL (default: `"https://huggingface.co/api/"`) * `REFRESH_INTERVAL_DAYS`: Number of days to consider an item "new" (default: `30`) * `ADDITIONAL_REPOS`: Array of forked or non-org GitHub repositories to include, formatted `/` (non-forks are included by default). Use `[]` if there are none you wish to include @@ -88,3 +88,7 @@ When first setting up your catalog, run the export script to generate a full lis > **Required token**: The weekly tag scan workflow requires a fine-grained access token with **Pull requests: Read and write** permission on the catalog repo. Follow the instructions in [App Authentication](app-authentication.md) to create and install a private Catalog Automation App for token generation. See **[tag-grouping-process.md](tag-grouping-process.md)** for full setup instructions, conventions, and guidance on using AI assistance for the initial grouping pass. + +## Code Repository Platform Setup + +The default code repository platform for this catalog is GitHub. If you wish to use another supported platform (Codeberg), please note that the [tag export](../scripts/export-tags.js) and [fetch release](../scripts/fetch-releases.js) scripts may require header definition modifications to function properly. Workflows would also require token and other platform-specific updates if running from a non-GitHub repository. Otherwise, this app is set up to be able to run from Codeberg to fetch and display Codeberg repositories. diff --git a/docs/tag-grouping-process.md b/docs/tag-grouping-process.md index e73b013..452a227 100644 --- a/docs/tag-grouping-process.md +++ b/docs/tag-grouping-process.md @@ -2,11 +2,16 @@ ## What Are Tag Groups? -Tags on GitHub repos (topics) and Hugging Face repos (card metadata) are free-form text, so +Tags on code platform[^1] repos (topics) and Hugging Face repos (card metadata) are free-form text, so the same concept often appears under multiple spellings: `computer-vision`, `computer vision`, `cv`. Tag groups normalize this noise so the catalog filter dropdown shows one clean canonical tag instead of a dozen near-duplicates. +> [!NOTE] +> This workflow has only been run on GitHub. It may need platform-based adjustments to function as expected on other code platforms, such as Codeberg or GitLab. + +[^1]: GitHub, Codeberg, and GitLab. + Tag groups live in **`public/tag-groups.js`** as a plain JavaScript object: ```js diff --git a/index.html b/index.html index 7e96d4a..367c169 100644 --- a/index.html +++ b/index.html @@ -28,9 +28,8 @@ class="fixed top-6 right-6 hidden md:flex items-center gap-3 text-white py-2 px-4 rounded-lg shadow-md transition-all z-50 no-underline group font-sans border border-white/10">
- +
diff --git a/public/config.yaml b/public/config.yaml index 06b960d..7514f69 100644 --- a/public/config.yaml +++ b/public/config.yaml @@ -22,7 +22,7 @@ COLORS: tag: "#9bcb5e" # Tag background color (Light Green) # API & Behavior Settings -# Codebase platform for API calls and link generation. Supported values: "github", pending: "codeberg" or "gitlab". +# Codebase platform for API calls and link generation. Supported values: "github" or "codeberg", pending: "gitlab". PLATFORM: github # Dataset, model, and space (demo) default: Hugging Face, other platforms would require a custom module API_BASE_URL: "https://huggingface.co/api/" diff --git a/src/api/fetchCodeRepos.js b/src/api/fetchCodeRepos.js index a427a2b..a0f87ea 100644 --- a/src/api/fetchCodeRepos.js +++ b/src/api/fetchCodeRepos.js @@ -2,6 +2,23 @@ import { handleError } from '../ui/render.js'; import { normalizeTag, filterDisplayTags } from '../utils/normalizeTag.js'; import { getPlatformDisplay } from '../utils/defineRibbonVals.js'; +// Define platform-specific values +const PLATFORM_CONFIGS = { + github: { + starsKey: 'stargazers_count', + profileRepo: '.github' + }, + /* gitlab: { + starsKey: 'star_count', + profileRepo: 'gitlab-profile', + //TODO + }, */ + codeberg: { + starsKey: 'stars_count', + profileRepo: '.profile' + } +}; + /** * Function for fetching code repositories from the specified platform (GitHub, GitLab, or Codeberg). * Both org-owned (non-forks) and additional repos are fetched; metadata is processed for each repo. @@ -28,6 +45,9 @@ export async function fetchCodeRepos( let allRepos = []; let nextUrl = `${orgApiUrl}`; + // get platform-specific keys + const platformConfig = PLATFORM_CONFIGS[platform.toLowerCase()]; + const { starsKey, profileRepo } = platformConfig; try { while (nextUrl) { const ghResponse = await fetch(nextUrl); @@ -72,7 +92,10 @@ export async function fetchCodeRepos( // Keep only non-forks from org; deduplicate against additional repos by full_name const orgRepoNames = new Set(filteredAdditionalRepos.map(r => r.full_name)); - const orgNonForks = allRepos.filter(repo => repo.name !== ".github" && !repo.fork && !orgRepoNames.has(repo.full_name)); + const orgNonForks = allRepos.filter(repo => + repo.name !== profileRepo && + !repo.fork && + !orgRepoNames.has(repo.full_name)); // Process additional repos and all remaining org non-forks to include metadata and 'new' flag as appropriate let processedItems = [...filteredAdditionalRepos, ...orgNonForks] @@ -105,7 +128,7 @@ export async function fetchCodeRepos( cardData: { pretty_name: repo.name, // , the one used for card title display description: repo.description, - stars: repo.stargazers_count || 0 + stars: repo[starsKey] ?? 0 } }; }); diff --git a/src/api/fetchStats.js b/src/api/fetchStats.js index fc0c0bd..76f6888 100644 --- a/src/api/fetchStats.js +++ b/src/api/fetchStats.js @@ -24,13 +24,13 @@ export const fetchCatalogStats = async (repoApiUrl, organizationName, catalogRep //TODO: Update stars and forks to support other platforms (GitLab, Codeberg) once implemented // 1. Get Stars & Forks const repo = await fetch(`${repoApiUrl}${organizationName}/${catalogRepoName}`).then(r => r.ok ? r.json() : {}); - if (repo.stargazers_count !== undefined) update('gh-stars', 'gh-star-container', repo.stargazers_count); + if (repo.stargazers_count !== undefined || repo.stars_count !== undefined) update('gh-stars', 'gh-star-container', repo.stargazers_count || repo.stars_count); if (repo.forks_count !== undefined) update('gh-forks', 'gh-fork-container', repo.forks_count); // 2. Get Version (Tag) // TODO: Import from package.json const release = await fetch(`${repoApiUrl}${organizationName}/${catalogRepoName}/releases/latest`).then(r => r.ok ? r.json() : {}); - if (release.tag_name) update('gh-tag', 'gh-version-container', release.tag_name); + if (release.tag_name !== undefined) update('gh-tag', 'gh-version-container', release.tag_name); } catch (e) { console.warn("Could not fetch Code Repo stats", e); diff --git a/src/assets/GitHub_Invertocat.svg b/src/assets/GitHub_Invertocat.svg new file mode 100755 index 0000000..5300969 --- /dev/null +++ b/src/assets/GitHub_Invertocat.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/src/assets/codeberg-logo_icon_white.svg b/src/assets/codeberg-logo_icon_white.svg new file mode 100644 index 0000000..74a0fa9 --- /dev/null +++ b/src/assets/codeberg-logo_icon_white.svg @@ -0,0 +1,164 @@ + + + Codeberg logo + + + + + + + + + + + + + + + + + + + + + image/svg+xml + + Codeberg logo + + + + Robert Martinez + + + + + Codeberg and the Codeberg Logo are trademarks of Codeberg e.V. + + + 2020-04-09 + + + Codeberg e.V. + + + codeberg.org + + + + + + + + + + + + + diff --git a/src/ui/initUserInterface.js b/src/ui/initUserInterface.js index 8dd1691..c8c250d 100644 --- a/src/ui/initUserInterface.js +++ b/src/ui/initUserInterface.js @@ -55,8 +55,8 @@ export const initializeUIFromConfig = (config) => { const platformDisplay = getPlatformDisplay(config.PLATFORM); if (repoRibbon && platformDisplay) { repoRibbon.href = `${platformDisplay.ribbonUrl}${config.ORGANIZATION_NAME}/${config.CATALOG_REPO_NAME}`; - const pathElement = document.getElementById('repo-ribbon-icon'); - pathElement.setAttribute('d', platformDisplay.path); + const svgElement = document.getElementById('repo-ribbon-icon-container'); + svgElement.innerHTML = platformDisplay.svg; const platformDisplayName = document.getElementById('platform-display-name'); platformDisplayName.textContent = platformDisplay.displayName || config.PLATFORM; repoRibbon.style.backgroundColor = config.COLORS.secondary; diff --git a/src/utils/defineApiUrls.js b/src/utils/defineApiUrls.js index 5b20e43..2d68025 100644 --- a/src/utils/defineApiUrls.js +++ b/src/utils/defineApiUrls.js @@ -1,17 +1,17 @@ /** - * Defines API URLs based on the selected platform (GitHub, note: GitLab and Codeberg support under development). + * Defines API URLs based on the selected platform (GitHub or Codeberg, note: GitLab support under development). * This allows the rest of the codebase to use these constants when making API calls, * abstracting away platform-specific URL structures. - * + * * Usage: import { getPlatformApiUrls } from './defineApiUrls.js'; - * + * * Input: platform and organizationName (e.g., 'github' and 'imageomics'), defined from config.yaml and passed to this function. * Output: platformApiUrls[platform] = { org: ORG_API_URL, repo: REPO_API_URL } */ /** * Utility function to get the platform-specific API URLs for organization repos and individual repo details. - * @param {string} platform - 'github', pending: 'gitlab', or 'codeberg' + * @param {string} platform - 'github' or 'codeberg', pending: 'gitlab' * @param {string} organizationName - The name of the organization (used in URL construction) * @returns {object} An object containing ORG_API_URL and REPO_API_URL */ @@ -25,10 +25,10 @@ export function getPlatformApiUrls(platform, organizationName) { // org: `https://gitlab.com/api/v4/groups/${organizationName}/projects?per_page=100`, // repo: "https://gitlab.com/api/v4/projects/" // }, - // codeberg: { - // org: `https://codeberg.org/api/v1/orgs/${organizationName}/repos?limit=50`, - // repo: "https://codeberg.org/api/v1/repos/" - // } + codeberg: { + org: `https://codeberg.org/api/v1/orgs/${organizationName}/repos?limit=50`, + repo: "https://codeberg.org/api/v1/repos/" + } }; return platformApiUrls[platform.toLowerCase()]; } diff --git a/src/utils/defineRibbonVals.js b/src/utils/defineRibbonVals.js index 9ab64a0..7a4486d 100644 --- a/src/utils/defineRibbonVals.js +++ b/src/utils/defineRibbonVals.js @@ -1,16 +1,19 @@ /** - * A collection of platform-specific display information (e.g., SVG path data) - * for code developer platforms including GitHub, pending: GitLab and Codeberg. + * A collection of platform-specific display information (e.g., SVGs and URLs) + * for code developer platforms including GitHub and Codeberg, pending: GitLab. * Used for linking to repo and rendering icon and platform name in the ribbon component of the UI. - * All paths are optimized for a 0 0 24 24 viewBox. * * Usage: import { getPlatformDisplay } from './defineRibbonVals.js'; * * Input: platform (e.g., 'github'), defined from config.yaml and passed to this function. - * Output: platformDisplays[platform] = { path: SVG_PATH_DATA, viewBox: VIEWBOX_DATA, + * Output: platformDisplays[platform] = { svg: CODE_PLATFORM_SVG, * displayName: DISPLAY_NAME, ribbonUrl: RIBBON_URL } */ +import githubSvg from '../assets/GitHub_Invertocat.svg?raw'; +// import gitlabSvg from '../assets/gitlab-logo-700-rgb.svg?raw'; +import codebergSvg from '../assets/codeberg-logo_icon_white.svg?raw'; + /** * Utility function to get the full platform display information * @param {string} platform - 'github', 'gitlab', or 'codeberg' @@ -19,25 +22,20 @@ export function getPlatformDisplay(platform) { const platformDisplays = { github: { - path: "M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0112 6.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0022 12.017C22 6.484 17.522 2 12 2z", - viewBox: "0 0 24 24", + svg: githubSvg, displayName: "GitHub", ribbonUrl: "https://github.com/" }, // gitlab: { - // path: "m23.71 11.83-2.13-6.52a.89.89 0 0 0-1.7 0l-2.13 6.52H6.25L4.12 5.31a.89.89 0 0 0-1.7 0L.29 11.83a1 1 0 0 0 .37 1.13l11.34 8.24 11.34-8.24a1 1 0 0 0 .37-1.13Z", - // viewBox: "0 0 24 24", - // color: "#FC6D26", + // svg: gitlabSvg, // displayName: "GitLab", // ribbonUrl: "https://gitlab.com/" //}, - // codeberg: { - // path: "M12.004 2.378a.636.636 0 0 0-.547.31L1.258 19.874a.636.636 0 0 0 .547.954h1.94c.143 0 .278-.074.354-.197L12 6.544l7.89 14.087a.406.406 0 0 0 .354.197h2.012a.636.636 0 0 0 .546-.954L12.551 2.688a.636.636 0 0 0-.547-.31Z", - // viewBox: "0 0 24 24", - // color: "#2185d0", - // displayName: "Codeberg", - // ribbonUrl: "https://codeberg.org/" - //} + codeberg: { + svg: codebergSvg, + displayName: "Codeberg", + ribbonUrl: "https://codeberg.org/" + } }; return platformDisplays[platform.toLowerCase()]; } diff --git a/src/validateConfig.js b/src/validateConfig.js index d903afe..78264d8 100644 --- a/src/validateConfig.js +++ b/src/validateConfig.js @@ -31,11 +31,11 @@ export function validateConfig(config) { } if (!config.ORG_NAME) errors.push('ORG_NAME'); if (!config.CATALOG_REPO_NAME) errors.push('CATALOG_REPO_NAME'); - - /** Update to include 'codeberg' and 'gitlab' once supported + + /** Update to include 'gitlab' once supported * PLATFORM will be parsed without whitespace, but must be string to avoid undefined errors */ - const supportedPlatforms = ['github']; + const supportedPlatforms = ['github', 'codeberg']; const platform = config.PLATFORM; if (!platform || typeof platform !== 'string') { errors.push('PLATFORM'); diff --git a/tests/api/fetchCodeRepos.test.js b/tests/api/fetchCodeRepos.test.js index 98c594b..55e815e 100644 --- a/tests/api/fetchCodeRepos.test.js +++ b/tests/api/fetchCodeRepos.test.js @@ -14,7 +14,32 @@ vi.mock('../../src/utils/defineRibbonVals.js', () => ({ getPlatformDisplay: vi.fn(() => 'GitHub') })); -describe('fetchCodeRepos', () => { +// define configs for each platform +const platformConfigs = [ + { + name: 'github', + orgApiUrl: 'https://api.github.com/orgs/test-org/repos', + repoApiUrl: 'https://api.github.com/repos/', + platformProfileRepo: '.github', + starsKey: 'stargazers_count' + }, + /* { + name: 'gitlab', + orgApiUrl: 'https://gitlab.com/api/v4/groups/test-org/projects', + repoApiUrl: 'https://gitlab.com/api/v4/projects/', + platformProfileRepo: 'gitlab-profile', + starsKey: 'star_count' + }, */ + { + name: 'codeberg', + orgApiUrl: 'https://codeberg.org/api/v1/orgs/test-org/repos', + repoApiUrl: 'https://codeberg.org/api/v1/repos/', + platformProfileRepo: '.profile', + starsKey: 'stars_count' + } +]; + +describe.each(platformConfigs)('fetchCodeRepos - $name', (platformConfig) => { const originalFetch = global.fetch; beforeEach(() => { @@ -25,8 +50,11 @@ describe('fetchCodeRepos', () => { vi.clearAllMocks(); }); - const orgApiUrl = 'https://api.github.com/orgs/test-org/repos'; - const repoApiUrl = 'https://api.github.com/repos/'; + const orgApiUrl = platformConfig.orgApiUrl; + const repoApiUrl = platformConfig.repoApiUrl; + const platform = platformConfig.name; + const starsKey = platformConfig.starsKey; + const platformProfileRepo = platformConfig.platformProfileRepo; const refreshIntervalDays = 30; const releasesMap = {}; @@ -37,7 +65,7 @@ describe('fetchCodeRepos', () => { const oldestDateISO = new Date(now.getTime() - 90 * 24 * 60 * 60 * 1000).toISOString(); // 90 days ago it('maps raw repo data and attaches release data from releasesMap', async () => { - // Mock a single page GitHub response + // Mock a single page platform response global.fetch.mockResolvedValueOnce({ ok: true, headers: { get: () => null }, // No 'link' header means no pagination @@ -47,7 +75,7 @@ describe('fetchCodeRepos', () => { updated_at: now.toISOString(), created_at: recentDateISO, topics: ['python'], - stargazers_count: 42, + [starsKey]: 42, }]) }); @@ -57,7 +85,7 @@ describe('fetchCodeRepos', () => { }; const items = await fetchCodeRepos( - 'github', + platform, [], // No additional repos orgApiUrl, repoApiUrl, @@ -108,7 +136,7 @@ describe('fetchCodeRepos', () => { }); const items = await fetchCodeRepos( - 'github', + platform, [], orgApiUrl, repoApiUrl, @@ -164,7 +192,7 @@ describe('fetchCodeRepos', () => { ]; const items = await fetchCodeRepos( - 'github', + platform, additionalRepos, orgApiUrl, repoApiUrl, @@ -189,8 +217,8 @@ describe('fetchCodeRepos', () => { expect(items.map(i => i.id)).toContain('external-org/external-additional'); }); - // Test that forks and .github repos are excluded from the final list - it('excludes repository targets designated as forks and .github', async () => { + // Test that forks and platform profile repos are excluded from the final list + it('excludes repository targets designated as forks and platform profile repos', async () => { global.fetch.mockResolvedValueOnce({ ok: true, headers: { get: () => null }, @@ -217,8 +245,8 @@ describe('fetchCodeRepos', () => { updated_at: recentDateISO }, { - full_name: 'test-org/.github', - name: '.github', + full_name: `test-org/${platformProfileRepo}`, + name: platformProfileRepo, fork: false, created_at: recentDateISO, updated_at: recentDateISO @@ -231,7 +259,7 @@ describe('fetchCodeRepos', () => { ]; const items = await fetchCodeRepos( - 'github', + platform, additionalRepos, orgApiUrl, repoApiUrl, @@ -242,7 +270,7 @@ describe('fetchCodeRepos', () => { expect(items.map(i => i.id)).toContain('test-org/valid-repo'); expect(items.map(i => i.id)).toContain('test-org/forked-in-list'); expect(items.map(i => i.id)).not.toContain('test-org/forked-repo'); - expect(items.map(i => i.id)).not.toContain('test-org/.github'); + expect(items.map(i => i.id)).not.toContain(`test-org/${platformProfileRepo}`); }); // Test that isNew flag is set correctly based on creation date and refresh interval @@ -267,7 +295,7 @@ describe('fetchCodeRepos', () => { }); const items = await fetchCodeRepos( - 'github', + platform, [], orgApiUrl, repoApiUrl, @@ -292,7 +320,7 @@ describe('fetchCodeRepos', () => { }); const items = await fetchCodeRepos( - 'github', + platform, [], orgApiUrl, repoApiUrl, @@ -302,7 +330,7 @@ describe('fetchCodeRepos', () => { expect(handleError).toHaveBeenCalledWith( expect.any(Error), - expect.stringContaining('Failed to fetch code from github') + expect.stringContaining(`Failed to fetch code from ${platform}`) ); expect(items).toEqual([]); }); From 1604489499e55643986f116695bddb34404a7198 Mon Sep 17 00:00:00 2001 From: Elizabeth Campolongo <38985481+egrace479@users.noreply.github.com> Date: Fri, 7 Aug 2026 11:14:57 -0500 Subject: [PATCH 2/6] Struct/platform keys (#126) * Consolidate platform-specific non-ui value fetches in dedicated file Reduces platform-specific edits required to token-handling in scripts * Remove duplicated key from return fallback defined at UI render * Revise data mapping test --- main.js | 33 ++++++---- scripts/export-tags.js | 7 ++- scripts/fetch-releases.js | 17 +++--- src/api/fetchCodeRepos.js | 40 ++++-------- src/api/fetchStats.js | 8 ++- src/utils/defineApiUrls.js | 34 ----------- src/utils/definePlatformVals.js | 78 ++++++++++++++++++++++++ tests/api/fetchCodeRepos.test.js | 101 ++++++++++++++++++------------- 8 files changed, 185 insertions(+), 133 deletions(-) delete mode 100644 src/utils/defineApiUrls.js create mode 100644 src/utils/definePlatformVals.js diff --git a/main.js b/main.js index 3fe7cb4..d47902b 100644 --- a/main.js +++ b/main.js @@ -10,7 +10,7 @@ import { load } from 'js-yaml'; import { initializeUIFromConfig, setThemeToggle } from './src/ui/initUserInterface.js'; import { parseUrlParams, updateUrlParams, getCurrentState } from './src/ui/urlManager.js'; import { renderItemList } from './src/ui/render.js'; -import { getPlatformApiUrls } from './src/utils/defineApiUrls.js'; +import { getPlatformApiUrls } from './src/utils/definePlatformVals.js'; import { filterItems, sortItems } from './src/utils/filterAndSort.js'; import { fetchCodeRepos } from './src/api/fetchCodeRepos.js'; import { fetchHfRepos } from './src/api/fetchHfRepos.js'; @@ -28,7 +28,7 @@ const configPromise = fetch('config.yaml') // Module-scope lets — assigned after config loads, used by all functions below let CONFIG; let ORGANIZATION_NAME, HF_ORGANIZATION_NAME, CATALOG_REPO_NAME, PLATFORM, API_BASE_URL, REFRESH_INTERVAL_DAYS, ADDITIONAL_REPOS, ADDITIONAL_HF_REPOS; -let ORG_API_URL, REPO_API_URL; +let ORG_API_URL, REPO_API_URL, RELEASE_SUFFIX; let releasesMap = {}; @@ -206,16 +206,23 @@ document.addEventListener('DOMContentLoaded', async () => { } // Assign module-scope variables used by all functions - ORGANIZATION_NAME = CONFIG.ORGANIZATION_NAME; - HF_ORGANIZATION_NAME = CONFIG.HF_ORGANIZATION_NAME; - CATALOG_REPO_NAME = CONFIG.CATALOG_REPO_NAME; - PLATFORM = CONFIG.PLATFORM; - ORG_API_URL = getPlatformApiUrls(PLATFORM, ORGANIZATION_NAME).org; - REPO_API_URL = getPlatformApiUrls(PLATFORM, ORGANIZATION_NAME).repo; - API_BASE_URL = CONFIG.API_BASE_URL; - REFRESH_INTERVAL_DAYS = CONFIG.REFRESH_INTERVAL_DAYS; - ADDITIONAL_REPOS = CONFIG.ADDITIONAL_REPOS; - ADDITIONAL_HF_REPOS = CONFIG.ADDITIONAL_HF_REPOS; + // Destructure CONFIG into individual variables for easier access + ({ + ORGANIZATION_NAME, + HF_ORGANIZATION_NAME, + CATALOG_REPO_NAME, + PLATFORM, + API_BASE_URL, + REFRESH_INTERVAL_DAYS, + ADDITIONAL_REPOS, + ADDITIONAL_HF_REPOS + } = CONFIG); + // Destructure platform-specific API URLs from getPlatformApiUrls + ({ + org: ORG_API_URL, + repo: REPO_API_URL, + releaseSuffix: RELEASE_SUFFIX + } = getPlatformApiUrls(PLATFORM, ORGANIZATION_NAME)); // Guard: if ORGANIZATION_NAME or HF_ORGANIZATION_NAME is missing (e.g. config.yaml failed to load), // stop here — proceeding would fire requests like ?author=&full=true which @@ -289,7 +296,7 @@ document.addEventListener('DOMContentLoaded', async () => { }); // Initialize the Catalog Badge (Stars/Forks/Version) - fetchCatalogStats(REPO_API_URL, ORGANIZATION_NAME, CATALOG_REPO_NAME) + fetchCatalogStats(REPO_API_URL, ORGANIZATION_NAME, CATALOG_REPO_NAME, RELEASE_SUFFIX) // Load pre-built release data (written by scripts/fetch-releases.js at build time) releasesMap = await fetch('./releases.json') diff --git a/scripts/export-tags.js b/scripts/export-tags.js index 7ab2497..ed18d6f 100644 --- a/scripts/export-tags.js +++ b/scripts/export-tags.js @@ -17,9 +17,9 @@ import path from 'path'; import { fileURLToPath } from 'url'; import { load } from 'js-yaml'; import { validateConfig } from '../src/validateConfig.js'; -import { getPlatformApiUrls } from '../src/utils/defineApiUrls.js'; import { getPlatformDisplay } from '../src/utils/defineRibbonVals.js'; import { filterNewAdditionalEntries } from '../src/utils/filterNewAdditionalEntries.js'; +import { getPlatformVals, getPlatformApiUrls } from '../src/utils/definePlatformVals.js'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); @@ -60,6 +60,7 @@ const get = async (url) => { // --------------------------------------------------------------------------- const allTags = new Set(); const { org: ORG_API_URL, repo: REPO_API_URL } = getPlatformApiUrls(PLATFORM, ORGANIZATION_NAME); +const { profileRepo, fullNameKey, forkKey } = getPlatformVals(PLATFORM); const collectCodePlatformTags = async () => { const platformDisplay = getPlatformDisplay(PLATFORM); @@ -85,8 +86,8 @@ const collectCodePlatformTags = async () => { ); const additionalRepos = additionalData.filter(Boolean); - const additionalNames = new Set(additionalRepos.map(r => r.full_name)); - const orgNonForks = allRepos.filter(r => r.name !== '.github' && !r.fork && !additionalNames.has(r.full_name)); + const additionalNames = new Set(additionalRepos.map(r => r[fullNameKey])); + const orgNonForks = allRepos.filter(r => r.name !== profileRepo && !r[forkKey] && !additionalNames.has(r[fullNameKey])); [...additionalRepos, ...orgNonForks].forEach(repo => { (repo.topics || []).forEach(t => allTags.add(t.toLowerCase())); diff --git a/scripts/fetch-releases.js b/scripts/fetch-releases.js index 933ce34..4d3f977 100644 --- a/scripts/fetch-releases.js +++ b/scripts/fetch-releases.js @@ -6,7 +6,7 @@ import { fileURLToPath } from 'url'; import { dirname, join } from 'path'; import { load } from 'js-yaml'; import { validateConfig } from '../src/validateConfig.js'; -import { getPlatformApiUrls } from '../src/utils/defineApiUrls.js'; +import { getPlatformVals, getPlatformApiUrls } from '../src/utils/definePlatformVals.js'; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); @@ -28,7 +28,8 @@ const headers = TOKEN const TWO_WEEKS_MS = 14 * 24 * 60 * 60 * 1000; // Step 1: Fetch all public org repos (paginated, same logic as main.js) -const { org: ORG_API_URL, repo: REPO_API_URL } = getPlatformApiUrls(CONFIG.PLATFORM, CONFIG.ORGANIZATION_NAME); +const { org: ORG_API_URL, repo: REPO_API_URL, releaseSuffix: RELEASE_SUFFIX } = getPlatformApiUrls(CONFIG.PLATFORM, CONFIG.ORGANIZATION_NAME); +const { profileRepo, fullNameKey, forkKey, urlKey, releasePublishedAtKey } = getPlatformVals(CONFIG.PLATFORM); let allOrgRepos = []; let nextUrl = `${ORG_API_URL}`; while (nextUrl) { @@ -47,24 +48,24 @@ while (nextUrl) { // Step 2: Collect all code repo IDs (mirrors main.js deduplication logic) const additionalRepoIds = CONFIG.ADDITIONAL_REPOS || []; const additionalRepoSet = new Set(additionalRepoIds); -const orgNonForks = allOrgRepos.filter(r => r.name !== '.github' && !r.fork && !additionalRepoSet.has(r.full_name)); +const orgNonForks = allOrgRepos.filter(r => r.name !== profileRepo && !r[forkKey] && !additionalRepoSet.has(r[fullNameKey])); const repoIds = [ ...additionalRepoIds, - ...orgNonForks.map(r => r.full_name), + ...orgNonForks.map(r => r[fullNameKey]), ]; // Step 3: Fetch latest release for each repo const releases = {}; for (const id of repoIds) { try { - const res = await fetch(`${REPO_API_URL}${id}/releases/latest`, { headers }); + const res = await fetch(`${REPO_API_URL}${id}/${RELEASE_SUFFIX}`, { headers }); if (!res.ok) { releases[id] = null; continue; } const data = await res.json(); releases[id] = { tag: data.tag_name, - url: data.html_url, - publishedAt: data.published_at, - isNew: (Date.now() - new Date(data.published_at)) < TWO_WEEKS_MS, + url: data[urlKey], + publishedAt: data[releasePublishedAtKey], + isNew: (Date.now() - new Date(data[releasePublishedAtKey])) < TWO_WEEKS_MS, }; } catch { releases[id] = null; diff --git a/src/api/fetchCodeRepos.js b/src/api/fetchCodeRepos.js index a0f87ea..5f6dba4 100644 --- a/src/api/fetchCodeRepos.js +++ b/src/api/fetchCodeRepos.js @@ -1,23 +1,7 @@ import { handleError } from '../ui/render.js'; -import { normalizeTag, filterDisplayTags } from '../utils/normalizeTag.js'; +import { getPlatformVals } from '../utils/definePlatformVals.js'; import { getPlatformDisplay } from '../utils/defineRibbonVals.js'; - -// Define platform-specific values -const PLATFORM_CONFIGS = { - github: { - starsKey: 'stargazers_count', - profileRepo: '.github' - }, - /* gitlab: { - starsKey: 'star_count', - profileRepo: 'gitlab-profile', - //TODO - }, */ - codeberg: { - starsKey: 'stars_count', - profileRepo: '.profile' - } -}; +import { normalizeTag, filterDisplayTags } from '../utils/normalizeTag.js'; /** * Function for fetching code repositories from the specified platform (GitHub, GitLab, or Codeberg). @@ -46,8 +30,7 @@ export async function fetchCodeRepos( let allRepos = []; let nextUrl = `${orgApiUrl}`; // get platform-specific keys - const platformConfig = PLATFORM_CONFIGS[platform.toLowerCase()]; - const { starsKey, profileRepo } = platformConfig; + const { starsKey, profileRepo, fullNameKey, forkKey, urlKey } = getPlatformVals(platform); try { while (nextUrl) { const ghResponse = await fetch(nextUrl); @@ -68,7 +51,7 @@ export async function fetchCodeRepos( // For org-owned entries in additionalRepos, reuse data already in allRepos to avoid redundant API calls. // Only fetch entries that belong to a different org (external repos). - const allReposByFullName = new Map(allRepos.map(r => [r.full_name, r])); + const allReposByFullName = new Map(allRepos.map(r => [r[fullNameKey], r])); const toFetch = additionalRepos.filter(ownerRepo => !allReposByFullName.has(ownerRepo)); const fromAllRepos = additionalRepos.map(ownerRepo => allReposByFullName.get(ownerRepo)).filter(Boolean); @@ -91,11 +74,11 @@ export async function fetchCodeRepos( const filteredAdditionalRepos = [...fromAllRepos, ...fetchedExternalData.filter(Boolean)]; // Keep only non-forks from org; deduplicate against additional repos by full_name - const orgRepoNames = new Set(filteredAdditionalRepos.map(r => r.full_name)); + const orgRepoNames = new Set(filteredAdditionalRepos.map(r => r[fullNameKey])); const orgNonForks = allRepos.filter(repo => repo.name !== profileRepo && - !repo.fork && - !orgRepoNames.has(repo.full_name)); + !repo[forkKey] && + !orgRepoNames.has(repo[fullNameKey])); // Process additional repos and all remaining org non-forks to include metadata and 'new' flag as appropriate let processedItems = [...filteredAdditionalRepos, ...orgNonForks] @@ -108,10 +91,10 @@ export async function fetchCodeRepos( const tags = [...new Set(rawTags.flatMap(t => normalizeTag(t)).filter(Boolean))]; const displayTags = filterDisplayTags(rawTags); - const release = releasesMap[repo.full_name] ?? null; + const release = releasesMap[repo[fullNameKey]] ?? null; return { - id: repo.full_name, // "Imageomics/", used as backup if can't get repo.name + id: repo[fullNameKey], // "Imageomics/", used as backup if can't get repo.name repoType: "code", createdAt, lastModified, @@ -120,14 +103,13 @@ export async function fetchCodeRepos( tags, rawTags, displayTags, - description: repo.description || "No description provided.", - html_url: repo.html_url, + description: repo.description, // fallback in display (render.js) + html_url: repo[urlKey], hasNewRelease: release?.isNew ?? false, latestReleaseUrl: release?.url ?? null, latestReleaseTag: release?.tag ?? null, cardData: { pretty_name: repo.name, // , the one used for card title display - description: repo.description, stars: repo[starsKey] ?? 0 } }; diff --git a/src/api/fetchStats.js b/src/api/fetchStats.js index 76f6888..a287db8 100644 --- a/src/api/fetchStats.js +++ b/src/api/fetchStats.js @@ -4,9 +4,10 @@ * @param {string} repoApiUrl - The base API URL for the code platform (e.g., GitHub API URL) * @param {string} organizationName - The organization name for the catalog repository * @param {string} catalogRepoName - The repository name for the catalog itself + * @param {string} releaseSuffix - The suffix used for fetching the latest release information (e.g., 'releases/latest') * @returns {Promise} - A promise that resolves when the stats have been fetched and displayed */ -export const fetchCatalogStats = async (repoApiUrl, organizationName, catalogRepoName) => { +export const fetchCatalogStats = async (repoApiUrl, organizationName, catalogRepoName, releaseSuffix) => { // Helper: Updates text, shows the specific stat, and ensures the divider is visible const update = (textId, containerId, value) => { const el = document.getElementById(textId); @@ -21,7 +22,8 @@ export const fetchCatalogStats = async (repoApiUrl, organizationName, catalogRep }; try { - //TODO: Update stars and forks to support other platforms (GitLab, Codeberg) once implemented + //TODO: Update stars and forks to support other platforms: Add another || star for GitLab, platform isn't passed + // forks_count is shared // 1. Get Stars & Forks const repo = await fetch(`${repoApiUrl}${organizationName}/${catalogRepoName}`).then(r => r.ok ? r.json() : {}); if (repo.stargazers_count !== undefined || repo.stars_count !== undefined) update('gh-stars', 'gh-star-container', repo.stargazers_count || repo.stars_count); @@ -29,7 +31,7 @@ export const fetchCatalogStats = async (repoApiUrl, organizationName, catalogRep // 2. Get Version (Tag) // TODO: Import from package.json - const release = await fetch(`${repoApiUrl}${organizationName}/${catalogRepoName}/releases/latest`).then(r => r.ok ? r.json() : {}); + const release = await fetch(`${repoApiUrl}${organizationName}/${catalogRepoName}/${releaseSuffix}`).then(r => r.ok ? r.json() : {}); if (release.tag_name !== undefined) update('gh-tag', 'gh-version-container', release.tag_name); } catch (e) { diff --git a/src/utils/defineApiUrls.js b/src/utils/defineApiUrls.js deleted file mode 100644 index 2d68025..0000000 --- a/src/utils/defineApiUrls.js +++ /dev/null @@ -1,34 +0,0 @@ -/** - * Defines API URLs based on the selected platform (GitHub or Codeberg, note: GitLab support under development). - * This allows the rest of the codebase to use these constants when making API calls, - * abstracting away platform-specific URL structures. - * - * Usage: import { getPlatformApiUrls } from './defineApiUrls.js'; - * - * Input: platform and organizationName (e.g., 'github' and 'imageomics'), defined from config.yaml and passed to this function. - * Output: platformApiUrls[platform] = { org: ORG_API_URL, repo: REPO_API_URL } - */ - -/** - * Utility function to get the platform-specific API URLs for organization repos and individual repo details. - * @param {string} platform - 'github' or 'codeberg', pending: 'gitlab' - * @param {string} organizationName - The name of the organization (used in URL construction) - * @returns {object} An object containing ORG_API_URL and REPO_API_URL - */ -export function getPlatformApiUrls(platform, organizationName) { - const platformApiUrls = { - github: { - org: `https://api.github.com/orgs/${organizationName}/repos?type=public&per_page=100`, - repo: "https://api.github.com/repos/" - }, - // gitlab: { - // org: `https://gitlab.com/api/v4/groups/${organizationName}/projects?per_page=100`, - // repo: "https://gitlab.com/api/v4/projects/" - // }, - codeberg: { - org: `https://codeberg.org/api/v1/orgs/${organizationName}/repos?limit=50`, - repo: "https://codeberg.org/api/v1/repos/" - } - }; - return platformApiUrls[platform.toLowerCase()]; -} diff --git a/src/utils/definePlatformVals.js b/src/utils/definePlatformVals.js new file mode 100644 index 0000000..2cb9784 --- /dev/null +++ b/src/utils/definePlatformVals.js @@ -0,0 +1,78 @@ +/** + * Collection of functions to define platform-specific values for the catalog, such as API URLs, display values, and repo data keys. + * Each function returns dictionaries with the platform values that have been keyed by platform name (e.g., 'github', 'codeberg', 'gitlab') and potentially other instance-specific values (e.g., organizationName, repoApiUrl). + * Platform and organization name are defined from config.yaml and passed to the functions requiring them. +*/ + + +/** + * Utility function to get platform-specific values for repo data keys (e.g., star count and profile repo name). + * @param {string} platform + * @returns {Object} platformVals - An object containing platform-specific values for repo data keys (e.g., star count and profile repo name) + */ +export function getPlatformVals(platform) { + const platformVals = { + github: { + starsKey: 'stargazers_count', + profileRepo: '.github', + fullNameKey: 'full_name', + forkKey: 'fork', //forks_count is shared + urlKey: 'html_url', + releasePublishedAtKey: 'published_at' + }, + /* gitlab: { + starsKey: 'star_count', + profileRepo: 'gitlab-profile', + fullNameKey: 'name_with_namespace', + forkKey: 'forked_from_project', + urlKey: 'web_url', + releasePublishedAtKey: 'released_at' + }, */ + codeberg: { + starsKey: 'stars_count', + profileRepo: '.profile', + fullNameKey: 'full_name', + forkKey: 'fork', + urlKey: 'html_url', + releasePublishedAtKey: 'published_at' + } +}; + return platformVals[platform.toLowerCase()]; +} + + +/** + * Utility function to get the platform-specific API URLs for organization repos and individual repo details. + * Defines API URLs based on the selected platform (GitHub or Codeberg, note: GitLab support under development). + * This allows the rest of the codebase to use these constants when making API calls, + * abstracting away platform-specific URL structures. + * + * Usage: import { getPlatformApiUrls } from './definePlatformVals.js'; + * + * Input: platform and organizationName (e.g., 'github' and 'imageomics'), defined from config.yaml and passed to this function. + * Output: platformApiUrls[platform] = { org: ORG_API_URL, repo: REPO_API_URL, releaseSuffix: RELEASE_SUFFIX } + * + * @param {string} platform - 'github' or 'codeberg', pending: 'gitlab' + * @param {string} organizationName - The name of the organization (used in URL construction) + * @returns {object} An object containing ORG_API_URL, REPO_API_URL, and RELEASE_SUFFIX + */ +export function getPlatformApiUrls(platform, organizationName) { + const platformApiUrls = { + github: { + org: `https://api.github.com/orgs/${organizationName}/repos?type=public&per_page=100`, + repo: "https://api.github.com/repos/", + releaseSuffix: "releases/latest" + }, + // gitlab: { + // org: `https://gitlab.com/api/v4/groups/${organizationName}/projects?per_page=100`, + // repo: "https://gitlab.com/api/v4/projects/", + // releaseSuffix: "releases/permalink/latest" + // }, + codeberg: { + org: `https://codeberg.org/api/v1/orgs/${organizationName}/repos?limit=50`, + repo: "https://codeberg.org/api/v1/repos/", + releaseSuffix: "releases/latest" + } + }; + return platformApiUrls[platform.toLowerCase()]; +} diff --git a/tests/api/fetchCodeRepos.test.js b/tests/api/fetchCodeRepos.test.js index 55e815e..6ae0fdd 100644 --- a/tests/api/fetchCodeRepos.test.js +++ b/tests/api/fetchCodeRepos.test.js @@ -1,6 +1,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { fetchCodeRepos } from '../../src/api/fetchCodeRepos.js'; import { handleError } from '../../src/ui/render.js'; +import { getPlatformVals, getPlatformApiUrls } from '../../src/utils/definePlatformVals.js'; // Mock the internal dependencies vi.mock('../../src/utils/normalizeTag.js', () => ({ @@ -10,34 +11,34 @@ vi.mock('../../src/utils/normalizeTag.js', () => ({ vi.mock('../../src/ui/render.js', () => ({ handleError: vi.fn() })); -vi.mock('../../src/utils/defineRibbonVals.js', () => ({ - getPlatformDisplay: vi.fn(() => 'GitHub') -})); -// define configs for each platform -const platformConfigs = [ - { - name: 'github', - orgApiUrl: 'https://api.github.com/orgs/test-org/repos', - repoApiUrl: 'https://api.github.com/repos/', - platformProfileRepo: '.github', - starsKey: 'stargazers_count' - }, - /* { - name: 'gitlab', - orgApiUrl: 'https://gitlab.com/api/v4/groups/test-org/projects', - repoApiUrl: 'https://gitlab.com/api/v4/projects/', - platformProfileRepo: 'gitlab-profile', - starsKey: 'star_count' - }, */ - { - name: 'codeberg', - orgApiUrl: 'https://codeberg.org/api/v1/orgs/test-org/repos', - repoApiUrl: 'https://codeberg.org/api/v1/repos/', - platformProfileRepo: '.profile', - starsKey: 'stars_count' +const SUPPORTED_PLATFORMS = ['github', 'codeberg']; // 'gitlab' is pending, tests should work on implementation +const TEST_ORG = 'test-org'; + +const platformConfigs = SUPPORTED_PLATFORMS.map(platform => { + const platformVals = getPlatformVals(platform); + const urls = getPlatformApiUrls(platform, TEST_ORG); + + return { + name: platform, + orgApiUrl: urls.org, + repoApiUrl: urls.repo, + starsKey: platformVals.starsKey, + platformProfileRepo: platformVals.profileRepo, + forkKey: platformVals.forkKey, + fullNameKey: platformVals.fullNameKey, + urlKey: platformVals.urlKey, + }; +}); + +const getMockForkValue = (isFork, platform) => { + if (!isFork) { + // GitLab leaves non-forks as undefined; GitHub/Codeberg return false + return platform === 'gitlab' ? undefined : false; } -]; + // GitLab returns parent project object; GitHub/Codeberg return true + return platform === 'gitlab' ? { id: 999 } : true; +}; describe.each(platformConfigs)('fetchCodeRepos - $name', (platformConfig) => { const originalFetch = global.fetch; @@ -55,6 +56,9 @@ describe.each(platformConfigs)('fetchCodeRepos - $name', (platformConfig) => { const platform = platformConfig.name; const starsKey = platformConfig.starsKey; const platformProfileRepo = platformConfig.platformProfileRepo; + const forkKey = platformConfig.forkKey; + const fullNameKey = platformConfig.fullNameKey; + const urlKey = platformConfig.urlKey; const refreshIntervalDays = 30; const releasesMap = {}; @@ -64,18 +68,21 @@ describe.each(platformConfigs)('fetchCodeRepos - $name', (platformConfig) => { const oldDateISO = new Date(now.getTime() - 45 * 24 * 60 * 60 * 1000).toISOString(); // 45 days ago const oldestDateISO = new Date(now.getTime() - 90 * 24 * 60 * 60 * 1000).toISOString(); // 90 days ago - it('maps raw repo data and attaches release data from releasesMap', async () => { + it('maps raw repo data, resolves platform keys, and attaches release data', async () => { // Mock a single page platform response global.fetch.mockResolvedValueOnce({ ok: true, headers: { get: () => null }, // No 'link' header means no pagination json: () => Promise.resolve([{ - full_name: 'test-org/code-repo', + [fullNameKey]: 'test-org/code-repo', name: 'code-repo', + description: 'A test repository', + [forkKey]: getMockForkValue(false, platform), updated_at: now.toISOString(), created_at: recentDateISO, topics: ['python'], [starsKey]: 42, + [urlKey]: 'http://example.com/test-org/code-repo' }]) }); @@ -94,15 +101,23 @@ describe.each(platformConfigs)('fetchCodeRepos - $name', (platformConfig) => { ); expect(items).toHaveLength(1); + expect(items[0].id).toBe('test-org/code-repo'); expect(items[0].repoType).toBe('code'); + expect(items[0].description).toBe('A test repository'); + // fork is used for inclusion filtering only and is not returned by fetchCodeRepos expect(items[0].hasNewRelease).toBe(true); expect(items[0].latestReleaseTag).toBe('v1.0'); expect(items[0].tags).toContain('python'); + expect(items[0].html_url).toBe('http://example.com/test-org/code-repo'); + expect(items[0].cardData.pretty_name).toBe('code-repo'); expect(items[0].cardData.stars).toBe(42); + + expect(global.fetch).toHaveBeenCalledTimes(1); + expect(global.fetch).toHaveBeenCalledWith(orgApiUrl); }); - // Pagination test: simulate multiple pages of GitHub API results using the Link header + // Pagination test: simulate multiple pages of API results using the Link header it('handles multi-page pagination', async () => { global.fetch.mockImplementation((url) => { if (url === orgApiUrl) { @@ -115,7 +130,7 @@ describe.each(platformConfigs)('fetchCodeRepos - $name', (platformConfig) => { : null }, json: () => Promise.resolve([{ - full_name: 'test-org/repo-page1', + [fullNameKey]: 'test-org/repo-page1', name: 'repo-page1', created_at: recentDateISO, updated_at: recentDateISO @@ -127,7 +142,7 @@ describe.each(platformConfigs)('fetchCodeRepos - $name', (platformConfig) => { ok: true, headers: { get: () => null }, json: () => Promise.resolve([{ - full_name: 'test-org/repo-page2', + [fullNameKey]: 'test-org/repo-page2', name: 'repo-page2', created_at: recentDateISO, updated_at: recentDateISO }]) @@ -156,14 +171,14 @@ describe.each(platformConfigs)('fetchCodeRepos - $name', (platformConfig) => { it('fetches additional org repos once and fetches external repos', async () => { // Base organization discovery lists 'test-org/internal-additional' const mockOrgRepo = { - full_name: 'test-org/internal-additional', + [fullNameKey]: 'test-org/internal-additional', name: 'internal-additional', created_at: recentDateISO, updated_at: recentDateISO }; const mockExternalRepo = { - full_name: 'external-org/external-additional', + [fullNameKey]: 'external-org/external-additional', name: 'external-additional', created_at: recentDateISO, updated_at: recentDateISO @@ -224,30 +239,30 @@ describe.each(platformConfigs)('fetchCodeRepos - $name', (platformConfig) => { headers: { get: () => null }, json: () => Promise.resolve([ { - full_name: 'test-org/valid-repo', + [fullNameKey]: 'test-org/valid-repo', name: 'valid-repo', - fork: false, + [forkKey]: getMockForkValue(false, platform), created_at: recentDateISO, updated_at: recentDateISO }, { - full_name: 'test-org/forked-repo', + [fullNameKey]: 'test-org/forked-repo', name: 'forked-repo', - fork: true, + [forkKey]: getMockForkValue(true, platform), created_at: recentDateISO, updated_at: recentDateISO }, { - full_name: 'test-org/forked-in-list', + [fullNameKey]: 'test-org/forked-in-list', name: 'forked-in-list', - fork: true, + [forkKey]: getMockForkValue(true, platform), created_at: recentDateISO, updated_at: recentDateISO }, { - full_name: `test-org/${platformProfileRepo}`, + [fullNameKey]: `test-org/${platformProfileRepo}`, name: platformProfileRepo, - fork: false, + [forkKey]: getMockForkValue(false, platform), created_at: recentDateISO, updated_at: recentDateISO } @@ -280,13 +295,13 @@ describe.each(platformConfigs)('fetchCodeRepos - $name', (platformConfig) => { headers: { get: () => null }, json: () => Promise.resolve([ { - full_name: 'test-org/new-repo', + [fullNameKey]: 'test-org/new-repo', name: 'new-repo', created_at: recentDateISO, updated_at: now.toISOString() }, { - full_name: 'test-org/old-repo', + [fullNameKey]: 'test-org/old-repo', name: 'old-repo', created_at: oldestDateISO, updated_at: oldDateISO From 22d90fc31a906c4bef17b3ae441aca13e6f04009 Mon Sep 17 00:00:00 2001 From: Elizabeth Campolongo <38985481+egrace479@users.noreply.github.com> Date: Mon, 10 Aug 2026 12:13:26 -0500 Subject: [PATCH 3/6] Clarify demo (HF space) jargon (#132) * Update terminology to be more general (use 'demos' instead of 'spaces') * Update documentation where relevant to match --- .zenodo.json | 3 ++- AGENTS.md | 6 +++--- CITATION.cff | 3 ++- CONTRIBUTING.md | 6 +++--- README.md | 2 +- index.html | 2 +- package.json | 3 ++- public/config.yaml | 6 +++--- 8 files changed, 17 insertions(+), 14 deletions(-) diff --git a/.zenodo.json b/.zenodo.json index c62a5da..94b0830 100644 --- a/.zenodo.json +++ b/.zenodo.json @@ -14,13 +14,14 @@ "affiliation": "The Ohio State University" } ], - "description": "

Catalog app for Imageomics code, data, models, and spaces hosted on GitHub and Hugging Face. It was designed for improved discoverability and searchability of Imageomics products, and the content is updated in real-time through calls to the GitHub and Hugging Face APIs.

\n

Catalog website: imageomics.github.io/catalog/.

\n

The repository also serves as a template for others looking to create a similar website to catalog their group's code, data, models, and demos.

", + "description": "

Catalog app for Imageomics code, data, models, and demos hosted on GitHub and Hugging Face. It was designed for improved discoverability and searchability of Imageomics products, and the content is updated in real-time through calls to the GitHub and Hugging Face APIs.

\n

Catalog website: imageomics.github.io/catalog/.

\n

The repository also serves as a template for others looking to create a similar website to catalog their group's code, data, models, and demos.

", "keywords": [ "imageomics", "code", "data", "models", "spaces", + "demos", "GitHub", "Hugging Face", "APIs", diff --git a/AGENTS.md b/AGENTS.md index ec4160c..e51f5d8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,7 +4,7 @@ This file provides guidance for AI coding agents (e.g., GitHub Copilot) working ## Project Overview -This is a **template repository** for a web-based catalog of an organization's public code, datasets, models, and spaces. It fetches live data from the code repository platform (such as GitHub) API and the platform API for hosting datasets and models (Hugging Face) and renders a searchable, filterable catalog page as a static site via GitHub Pages. The default setup is for the Imageomics Organization's GitHub and Hugging Face products. +This is a **template repository** for a web-based catalog of an organization's public code, datasets, models, and demos. It fetches live data from the code repository platform (such as GitHub) API and the platform API for hosting datasets and models (Hugging Face) and renders a searchable, filterable catalog page as a static site via GitHub Pages. The default setup is for the Imageomics Organization's GitHub and Hugging Face products. ## Critical: Templated Design @@ -50,7 +50,7 @@ Resource type | List (org) URL | Detail (single repo) URL ---|---|--- Datasets | `{API_BASE_URL}datasets?author={org}&full=true` | `{API_BASE_URL}datasets/{owner}/{repo}` Models | `{API_BASE_URL}models?author={org}&full=true` | `{API_BASE_URL}models/{owner}/{repo}` -Spaces | `{API_BASE_URL}spaces?author={org}&full=true` | `{API_BASE_URL}spaces/{owner}/{repo}` +Spaces (demos) | `{API_BASE_URL}spaces?author={org}&full=true` | `{API_BASE_URL}spaces/{owner}/{repo}` `API_BASE_URL` defaults to `https://huggingface.co/api/` (set in `config.yaml`). @@ -60,7 +60,7 @@ Resource type | Link URL ---|--- Datasets | `https://huggingface.co/datasets/{owner}/{repo}` Models | `https://huggingface.co/{owner}/{repo}` *(no `/models/` prefix)* -Spaces | `https://huggingface.co/spaces/{owner}/{repo}` +Spaces (demos) | `https://huggingface.co/spaces/{owner}/{repo}` ### Models Require a Secondary Per-Model Fetch diff --git a/CITATION.cff b/CITATION.cff index 1864bae..b92c3b6 100644 --- a/CITATION.cff +++ b/CITATION.cff @@ -1,4 +1,4 @@ -abstract: "Catalog app for Imageomics code, data, models, and spaces hosted on GitHub and Hugging Face. It was designed for improved discoverability and searchability of Imageomics products, and the content is updated in real-time through calls to the GitHub and Hugging Face APIs. The repository also serves as a template for others looking to create a similar website to catalog their group's code, data, models, and demos." +abstract: "Catalog app for Imageomics code, data, models, and demos hosted on GitHub and Hugging Face. It was designed for improved discoverability and searchability of Imageomics products, and the content is updated in real-time through calls to the GitHub and Hugging Face APIs. The repository also serves as a template for others looking to create a similar website to catalog their group's code, data, models, and demos." authors: - family-names: "Campolongo" given-names: "Elizabeth G." @@ -25,6 +25,7 @@ keywords: - data - models - spaces + - demos - "GitHub" - "Hugging Face" - APIs diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3b3f1d3..a9f3ac8 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -4,9 +4,9 @@ Thank you for your interest in contributing to the catalog! This document outlin ## 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 catalog is built with JavaScript and relies on API calls to code, data, model, and demo 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. +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 demos in a dynamic, searchable site. The default setup is for the Imageomics Institute's GitHub and Hugging Face products. ## Getting Started @@ -24,7 +24,7 @@ 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. +Please refer to our [AGENTS.md](AGENTS.md). This also includes special notes on key differences between the repo types (datasets, models, and spaces/demos) in Hugging Face API calls and returns, as well as important considerations for the templated design of this repository. ## Contribution Process diff --git a/README.md b/README.md index f038f2a..cabb626 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Imageomics Catalog [![DOI](https://zenodo.org/badge/1054290236.svg)](https://doi.org/10.5281/zenodo.17602801) -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. +Repository for web-based catalog of Imageomics code, data, models, and demos. 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 (demos) 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 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/). diff --git a/index.html b/index.html index 367c169..4ff37ef 100644 --- a/index.html +++ b/index.html @@ -120,7 +120,7 @@

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

- -
- - - - + +
+
+ + + + +
+
diff --git a/package-lock.json b/package-lock.json index b8da410..99bceca 100644 --- a/package-lock.json +++ b/package-lock.json @@ -450,9 +450,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -470,9 +467,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -490,9 +484,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -510,9 +501,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -530,9 +518,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -550,9 +535,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -779,9 +761,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -799,9 +778,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -819,9 +795,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -839,9 +812,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1497,9 +1467,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -1521,9 +1488,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -1545,9 +1509,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -1569,9 +1530,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ diff --git a/src/ui/render.js b/src/ui/render.js index 754280f..6ef015d 100644 --- a/src/ui/render.js +++ b/src/ui/render.js @@ -143,6 +143,11 @@ const renderHubItemCard = (item) => { export const renderItemList = (items) => { const itemListElement = document.getElementById('itemList'); const emptyStateElement = document.getElementById('emptyState'); + const resultCountElement = document.getElementById('resultCount'); + + if (resultCountElement) { + resultCountElement.textContent = `${items.length} result${items.length !== 1 ? 's' : ''}`; + } if (items.length === 0) { itemListElement.innerHTML = ''; From 64f22fd8bd6526b37fc2fa51a75e7645bbfbd175 Mon Sep 17 00:00:00 2001 From: Elizabeth Campolongo <38985481+egrace479@users.noreply.github.com> Date: Mon, 17 Aug 2026 17:57:54 -0400 Subject: [PATCH 6/6] Prep provenance files for major release --- .zenodo.json | 6 ++++-- CITATION.cff | 14 ++++++++------ package-lock.json | 4 ++-- package.json | 2 +- 4 files changed, 15 insertions(+), 11 deletions(-) diff --git a/.zenodo.json b/.zenodo.json index 94b0830..8607408 100644 --- a/.zenodo.json +++ b/.zenodo.json @@ -23,6 +23,8 @@ "spaces", "demos", "GitHub", + "GitLab", + "Codeberg", "Hugging Face", "APIs", "inventory", @@ -32,9 +34,9 @@ "searchability" ], "title": "Imageomics Catalog", - "version": "4.3.0", + "version": "5.0.0", "license": "MIT", - "publication_date": "2026-07-08", + "publication_date": "2026-08-18", "grants": [ { "id": "021nxhr62::2118240" diff --git a/CITATION.cff b/CITATION.cff index b92c3b6..0969e01 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-07-08" +date-released: "2026-08-18" identifiers: - - description: "The GitHub release URL of tag v4.3.0." + - description: "The GitHub release URL of tag v5.0.0." type: url - value: "https://github.com/Imageomics/catalog/releases/tag/v4.3.0" - - description: "The GitHub URL of the commit tagged with v4.3.0." + value: "https://github.com/Imageomics/catalog/releases/tag/v5.0.0" + - description: "The GitHub URL of the commit tagged with v5.0.0." type: url - value: "https://github.com/Imageomics/catalog/tree/287b1a702ca3c4fd4c55ceb1949f8c1fb3e2b85b" # Update on release + value: "https://github.com/Imageomics/catalog/tree/" # Update on release keywords: - imageomics - code @@ -27,6 +27,8 @@ keywords: - spaces - demos - "GitHub" + - "GitLab" + - "Codeberg" - "Hugging Face" - APIs - inventory @@ -38,6 +40,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.3.0" +version: "5.0.0" doi: "10.5281/zenodo.17602801" type: software diff --git a/package-lock.json b/package-lock.json index 99bceca..bf4d1c5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "catalog", - "version": "4.3.0", + "version": "5.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "catalog", - "version": "4.3.0", + "version": "5.0.0", "license": "MIT", "dependencies": { "js-yaml": "^5.2.1" diff --git a/package.json b/package.json index 9a970fc..5506bf0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "catalog", - "version": "4.3.0", + "version": "5.0.0", "description": "Repository for web-based Imageomics code, data, model, and demos 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": {