diff --git a/AGENTS.md b/AGENTS.md index 2c07b4e..e601252 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -100,7 +100,8 @@ When adding a new utility to `src/`, add a corresponding test file. Do **not** a All runtime behavior is controlled by `public/config.yaml`. It is fetched at page load (not bundled), so changes take effect on the next page load without a rebuild. Key fields: -- `ORGANIZATION_NAME` — used in all API calls (must be lowercase) +- `ORGANIZATION_NAME` — used in all code repository platform API calls (GitHub is not case-sensitive) +- `HF_ORGANIZATION_NAME` — used in all Hugging Face API calls (**case-sensitive**) - `API_BASE_URL` — Hugging Face API base (default: `https://huggingface.co/api/`) - `PLATFORM` — code repository platform; only `github` is currently supported - `ADDITIONAL_REPOS` — forked or external GitHub repos to include diff --git a/docs/personalization.md b/docs/personalization.md index 3a6d161..cc26212 100644 --- a/docs/personalization.md +++ b/docs/personalization.md @@ -8,8 +8,9 @@ Welcome to your new catalog repo! The primary way to personalize this catalog is ### Organization & Repository Settings - * `ORGANIZATION_NAME`: Your GitHub/Hugging Face organization name (lowercase for API calls) - * `ORG_NAME`: Display name for your organization (can differ from API name); used as fallback site title if `CATALOG_TITLE` is not set + * `ORGANIZATION_NAME`: Your code platform (e.g., GitHub) organization name (for API calls) + * `HF_ORGANIZATION_NAME`: Your Hugging Face organization name (**case-sensitive**, for API calls) + * `ORG_NAME`: Display name for your organization (can differ from API name); used for logo alt-text and as fallback site title if `CATALOG_TITLE` is not set * `CATALOG_REPO_NAME`: Repository name for the catalog itself (used for stats badge) ### Branding diff --git a/main.js b/main.js index bf6c297..780d760 100644 --- a/main.js +++ b/main.js @@ -24,7 +24,7 @@ const configPromise = fetch('config.yaml') // Module-scope lets — assigned after config loads, used by all functions below let CONFIG; -let ORGANIZATION_NAME, CATALOG_REPO_NAME, PLATFORM, API_BASE_URL, REFRESH_INTERVAL_DAYS, ADDITIONAL_REPOS, ADDITIONAL_HF_REPOS; +let ORGANIZATION_NAME, HF_ORGANIZATION_NAME, CATALOG_REPO_NAME, PLATFORM, API_BASE_URL, REFRESH_INTERVAL_DAYS, ADDITIONAL_REPOS, ADDITIONAL_HF_REPOS; let ORG_API_URL, REPO_API_URL; // Build a reverse lookup from TAG_GROUPS (defined in tag-groups.js): raw tag → [canonical tags] @@ -279,7 +279,7 @@ const fetchHubItems = async (repoType) => { } // hugging face api requests for datasets/models/spaces - const response = await fetch(`${API_BASE_URL}${repoType}?author=${ORGANIZATION_NAME}&full=true`); + const response = await fetch(`${API_BASE_URL}${repoType}?author=${HF_ORGANIZATION_NAME}&full=true`); if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`); } @@ -651,7 +651,7 @@ const initializeUIFromConfig = () => { // Set header title and description const headerTitle = document.getElementById('header-title'); if (headerTitle) { - headerTitle.textContent = CONFIG.CATALOG_TITLE; + headerTitle.textContent = CONFIG.CATALOG_TITLE || `${CONFIG.ORG_NAME} Catalog`; headerTitle.style.color = CONFIG.COLORS.primary; } @@ -703,7 +703,7 @@ document.addEventListener('DOMContentLoaded', async () => { setTimeout(() => errorDiv.remove(), 10000); // Fall back to defaults so the page isn't completely broken CONFIG = { - ORGANIZATION_NAME: '', CATALOG_REPO_NAME: '', ORG_NAME: '', + ORGANIZATION_NAME: '', HF_ORGANIZATION_NAME: '', CATALOG_REPO_NAME: '', ORG_NAME: '', CATALOG_TITLE: 'Catalog', CATALOG_DESCRIPTION: '', LOGO_URL: '', FAVICON_URL: '', COLORS: { primary: '#92991c', secondary: '#5d8095', accent: '#0097b2', accentDark: '#4fd1eb', tag: '#9bcb5e' }, @@ -715,6 +715,7 @@ document.addEventListener('DOMContentLoaded', async () => { // Assign module-scope variables used by all functions ORGANIZATION_NAME = CONFIG.ORGANIZATION_NAME; + HF_ORGANIZATION_NAME = CONFIG.HF_ORGANIZATION_NAME; CATALOG_REPO_NAME = CONFIG.CATALOG_REPO_NAME; PLATFORM = CONFIG.PLATFORM; ORG_API_URL = getPlatformApiUrls(PLATFORM, ORGANIZATION_NAME).org; @@ -724,6 +725,14 @@ document.addEventListener('DOMContentLoaded', async () => { ADDITIONAL_REPOS = CONFIG.ADDITIONAL_REPOS; ADDITIONAL_HF_REPOS = CONFIG.ADDITIONAL_HF_REPOS; + // Guard: if ORGANIZATION_NAME or HF_ORGANIZATION_NAME is missing (e.g. config.yaml failed to load), + // stop here — proceeding would fire requests like ?author=&full=true which + // could return unbounded results from the Hugging Face API. + if (!ORGANIZATION_NAME || !HF_ORGANIZATION_NAME){ + console.error("Organization name is missing for one or both APIs. Halting initialization."); + return; + } + // Apply CSS custom properties and document metadata document.title = CONFIG.CATALOG_TITLE || `${CONFIG.ORG_NAME} Catalog`; document.documentElement.style.setProperty('--color-primary', CONFIG.COLORS?.primary || '#92991c'); @@ -808,11 +817,6 @@ document.addEventListener('DOMContentLoaded', async () => { }); // Initialize the Catalog Badge (Stars/Forks/Version) - // Guard: if ORGANIZATION_NAME is missing (e.g. config.yaml failed to load), - // stop here — proceeding would fire requests like ?author=&full=true which - // could return unbounded results from the Hugging Face API. - if (!ORGANIZATION_NAME) return; - fetchCatalogStats(); // Load pre-built release data (written by scripts/fetch-releases.js at build time) diff --git a/public/config.yaml b/public/config.yaml index 17ef860..06b960d 100644 --- a/public/config.yaml +++ b/public/config.yaml @@ -2,8 +2,9 @@ # Customize these values to personalize the catalog for your organization # Organization & Repository Settings -ORGANIZATION_NAME: imageomics # GitHub/Hugging Face organization name (lowercase for API calls) -ORG_NAME: Imageomics # Display name for GitHub organization (can differ from API name) +ORGANIZATION_NAME: imageomics # Codebase platform organization name (for API calls) +HF_ORGANIZATION_NAME: imageomics # Hugging Face organization name (case-sensitive, for API calls) +ORG_NAME: Imageomics # Display name for Codebase platform organization (can differ from API name) CATALOG_REPO_NAME: catalog # Repository name for the catalog itself (used for stats badge) # Branding @@ -43,6 +44,7 @@ ADDITIONAL_REPOS: # Array of Hugging Face repos from outside the org to include. # Each entry must specify "repo" (owner/name) and "type" (datasets, models, or spaces). +# Organization names are case-sensitive in the Hugging Face API. # ADDITIONAL_HF_REPOS: # - repo: "user/dataset-name" # type: "datasets" diff --git a/scripts/export-tags.js b/scripts/export-tags.js index f3ec2ba..8e046ec 100644 --- a/scripts/export-tags.js +++ b/scripts/export-tags.js @@ -35,7 +35,7 @@ if (errors.length) { } const CONFIG = rawConfig; -const { ORGANIZATION_NAME, PLATFORM, API_BASE_URL, ADDITIONAL_REPOS } = CONFIG; +const { ORGANIZATION_NAME, HF_ORGANIZATION_NAME, PLATFORM, API_BASE_URL, ADDITIONAL_REPOS } = CONFIG; const ADDITIONAL_HF_REPOS = CONFIG.ADDITIONAL_HF_REPOS; // --------------------------------------------------------------------------- @@ -97,7 +97,7 @@ const collectCodePlatformTags = async () => { const collectHFTags = async (repoType) => { console.log(`Fetching HF ${repoType}...`); - let items = (await get(`${API_BASE_URL}${repoType}?author=${ORGANIZATION_NAME}&full=true`)).json; + let items = (await get(`${API_BASE_URL}${repoType}?author=${HF_ORGANIZATION_NAME}&full=true`)).json; // Fetch additional HF repos of this type const additionalForType = ADDITIONAL_HF_REPOS.filter(entry => entry.type === repoType); diff --git a/src/validateConfig.js b/src/validateConfig.js index 7cac8f3..d903afe 100644 --- a/src/validateConfig.js +++ b/src/validateConfig.js @@ -14,15 +14,35 @@ export function validateConfig(config) { return errors; } - if (!config.ORGANIZATION_NAME) errors.push('ORGANIZATION_NAME'); + /** Validate organization names, existence and allowed characters */ + const ghOrgRegex = /^[a-zA-Z0-9-]+$/; + const orgName = config.ORGANIZATION_NAME; + if (typeof orgName !== 'string' || !orgName.trim()) { + errors.push('ORGANIZATION_NAME'); + } else if (!ghOrgRegex.test(orgName)) { + errors.push(`ORGANIZATION_NAME (${orgName}) is invalid, only letters, numbers, and hyphens are allowed`); + } + const hfOrgRegex = /^[a-zA-Z0-9_\-]+$/; + const hfOrgName = config.HF_ORGANIZATION_NAME; + if (typeof hfOrgName !== 'string' || !hfOrgName.trim()) { + errors.push('HF_ORGANIZATION_NAME'); + } else if (!hfOrgRegex.test(hfOrgName)) { + errors.push(`HF_ORGANIZATION_NAME (${hfOrgName}) is invalid, only letters, numbers, hyphens, and underscores are allowed`); + } if (!config.ORG_NAME) errors.push('ORG_NAME'); if (!config.CATALOG_REPO_NAME) errors.push('CATALOG_REPO_NAME'); - if (!config.PLATFORM) errors.push('PLATFORM'); - /** Update to include 'codeberg' and 'gitlab' once supported */ + + /** Update to include 'codeberg' and 'gitlab' once supported + * PLATFORM will be parsed without whitespace, but must be string to avoid undefined errors + */ const supportedPlatforms = ['github']; - if (config.PLATFORM && !supportedPlatforms.includes(config.PLATFORM.toLowerCase())) { + const platform = config.PLATFORM; + if (!platform || typeof platform !== 'string') { + errors.push('PLATFORM'); + } else if (!supportedPlatforms.includes(platform.toLowerCase())) { errors.push(`PLATFORM must be one of: ${supportedPlatforms.join(', ')}`); } + if (!config.API_BASE_URL) errors.push('API_BASE_URL'); if (config.REFRESH_INTERVAL_DAYS == null) errors.push('REFRESH_INTERVAL_DAYS'); diff --git a/tests/validateConfig.test.js b/tests/validateConfig.test.js index a9ee8c1..089e2e6 100644 --- a/tests/validateConfig.test.js +++ b/tests/validateConfig.test.js @@ -3,6 +3,7 @@ import { validateConfig } from '../src/validateConfig.js'; const VALID_CONFIG = { ORGANIZATION_NAME: 'imageomics', + HF_ORGANIZATION_NAME: 'imageomics', ORG_NAME: 'Imageomics', CATALOG_REPO_NAME: 'catalog', PLATFORM: 'github', @@ -53,6 +54,27 @@ describe('validateConfig', () => { expect(validateConfig({ ...VALID_CONFIG, ORGANIZATION_NAME: '' })).toContain('ORGANIZATION_NAME'); }); + it('errors when ORGANIZATION_NAME is not in accepted format', () => { + expect( + validateConfig({ ...VALID_CONFIG, ORGANIZATION_NAME: 'abc center' }) + ).toContain('ORGANIZATION_NAME (abc center) is invalid, only letters, numbers, and hyphens are allowed'); + }); + + it('errors when HF_ORGANIZATION_NAME is missing', () => { + const { HF_ORGANIZATION_NAME: _, ...config } = VALID_CONFIG; + expect(validateConfig(config)).toContain('HF_ORGANIZATION_NAME'); + }); + + it('errors when HF_ORGANIZATION_NAME is empty string', () => { + expect(validateConfig({ ...VALID_CONFIG, HF_ORGANIZATION_NAME: '' })).toContain('HF_ORGANIZATION_NAME'); + }); + + it('errors when HF_ORGANIZATION_NAME is not in accepted format', () => { + expect( + validateConfig({ ...VALID_CONFIG, HF_ORGANIZATION_NAME: 'abc center' }) + ).toContain('HF_ORGANIZATION_NAME (abc center) is invalid, only letters, numbers, hyphens, and underscores are allowed'); + }); + it('errors when ORG_NAME is missing', () => { const { ORG_NAME: _, ...config } = VALID_CONFIG; expect(validateConfig(config)).toContain('ORG_NAME'); @@ -75,6 +97,10 @@ describe('validateConfig', () => { const { PLATFORM: _, ...config } = VALID_CONFIG; expect(validateConfig(config)).toContain('PLATFORM'); }); + + it('errors when PLATFORM is not a string', () => { + expect(validateConfig({ ...VALID_CONFIG, PLATFORM: 123 })).toContain('PLATFORM'); + }); it('errors when PLATFORM is unrecognized', () => { const errors = validateConfig({ ...VALID_CONFIG, PLATFORM: 'blah' }); @@ -141,6 +167,7 @@ describe('validateConfig', () => { }; const errors = validateConfig(config); expect(errors).toContain('ORGANIZATION_NAME'); + expect(errors).toContain('HF_ORGANIZATION_NAME'); expect(errors).toContain('ORG_NAME'); expect(errors).toContain('CATALOG_REPO_NAME'); expect(errors).toContain('PLATFORM'); diff --git a/vite.config.js b/vite.config.js index c216b73..fc44854 100644 --- a/vite.config.js +++ b/vite.config.js @@ -1,5 +1,33 @@ import tailwindcss from '@tailwindcss/vite' import { defineConfig } from 'vite' +import fs from 'fs'; +import jsYaml from 'js-yaml'; +import { validateConfig } from './src/validateConfig.js'; + +const CONFIG_PATH = './public/config.yaml'; +let parsedConfig; + +// Run config validation before Vite starts (dev/build/preview) +try { + const fileContents = fs.readFileSync(CONFIG_PATH, 'utf8'); + parsedConfig = jsYaml.load(fileContents); +} catch (error) { + console.error(`\n❌ [CONFIG ERROR] Failed to read or parse ${CONFIG_PATH}: ${error.message}\n`); + process.exit(1); // Stop Vite immediately if the file is completely unreadable or broken YAML +} + +// Any errors will be collected in the 'errors' array, to be printed out and cause a safe crash if not empty. +const errors = validateConfig(parsedConfig); +if (errors && errors.length > 0) { + console.error('\n❌ [CONFIG ERROR] Vite failed to start due to the following configuration errors:\n'); + + errors.forEach((error, index) => { + console.error(` ${index + 1}. ${error}`); + }); + + console.error(`\n Please fix the listed issue(s) in ${CONFIG_PATH} and try again.\n`); + process.exit(1); // Safely kills the npm run dev terminal process +} const repoName = process.env.GITHUB_REPOSITORY?.split('/')[1]