From 5f1a6245114be2f262ba241711bc740260b45602 Mon Sep 17 00:00:00 2001 From: allison-truhlar Date: Mon, 2 Feb 2026 11:26:26 -0500 Subject: [PATCH 001/104] feat: add viewers configuration template --- docs/viewers.config.yaml.template | 51 +++++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 docs/viewers.config.yaml.template diff --git a/docs/viewers.config.yaml.template b/docs/viewers.config.yaml.template new file mode 100644 index 000000000..3883399ee --- /dev/null +++ b/docs/viewers.config.yaml.template @@ -0,0 +1,51 @@ +# Fileglancer OME-Zarr Viewers Configuration +# +# This file defines which OME-Zarr viewers are available in your Fileglancer deployment. +# The @bioimagetools/capability-manifest library is used to determine compatibility +# for viewers that have a capability manifest. +# +# To use this file: +# 1. Copy this template to the project root: cp docs/viewers.config.yaml.template viewers.config.yaml +# 2. Uncommented viewers will be shown in your deployment +# 3. Check the values provided for each viewer - see guidelines below. +# +# For viewers with capability manifests, you must provide: +# - name: must match name value in capability manifest +# Optionally: +# - url: to override the template url in the capability manifest +# - logo: to override the default logo at {name}.png +# - label: Custom tooltip text (defaults to "View in {Name}") +# +# For viewers without capability manifests, you must provide: +# - name: Viewer identifier +# - url: URL template (use `{dataLink}` placeholder for dataset URL) +# - ome_zarr_versions: Array of supported OME-Zarr versions (e.g., `[0.4, 0.5]`) +# Optionally: +# - logo: Filename of logo in `frontend/src/assets/` (defaults to `{name}.png` if not specified) +# - label: Custom tooltip text (defaults to "View in {Name}") + +viewers: + # OME-Zarr viewers with capability manifests + - name: neuroglancer + + - name: avivator + # Optional: Override the viewer URL from the capability manifest + # In this example, override to use Janelia's custom deployment + url: "https://janeliascicomp.github.io/viv/" + + # # OME-Zarr viewers without capability manifests + # # Example: + # OME-Zarr Validator + # Logo will automatically resolve to @/assets/validator.png + - name: validator + url: "https://ome.github.io/ome-ngff-validator/?source={dataLink}" + ome_zarr_versions: [0.4, 0.5] + label: "View in OME-Zarr Validator" + + # # Example: + # # Vol-E - Allen Cell Explorer 3D viewer + # # Logo will automatically resolve to @/assets/vole.png + - name: vole + url: "https://volumeviewer.allencell.org/viewer?url={dataLink}" + ome_zarr_versions: [0.4] + label: "View in Vol-E" From d0bf67c86b535d9ec054d27b4bd2811ed66933fb Mon Sep 17 00:00:00 2001 From: allison-truhlar Date: Mon, 2 Feb 2026 11:28:08 -0500 Subject: [PATCH 002/104] feat: add viewer configuration types and YAML parser --- frontend/.gitignore | 1 + frontend/src/config/viewerLogos.ts | 34 +++++++++ frontend/src/config/viewersConfig.ts | 110 +++++++++++++++++++++++++++ 3 files changed, 145 insertions(+) create mode 100644 frontend/.gitignore create mode 100644 frontend/src/config/viewerLogos.ts create mode 100644 frontend/src/config/viewersConfig.ts diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 000000000..c979e0f11 --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1 @@ +src/config/viewers.config.yaml diff --git a/frontend/src/config/viewerLogos.ts b/frontend/src/config/viewerLogos.ts new file mode 100644 index 000000000..33ed1cd53 --- /dev/null +++ b/frontend/src/config/viewerLogos.ts @@ -0,0 +1,34 @@ +import fallback_logo from "@/assets/error_icon_gradient.png"; + +/** + * Fallback logo for viewers without a specified logo + */ +export const FALLBACK_LOGO = fallback_logo; + +/** + * Get logo path for a viewer + * Logo resolution order: + * 1. If customLogoPath is provided, use that from @/assets/ + * 2. If not, try to load @/assets/{viewerName}.png + * 3. If not found, use fallback logo + * + * @param viewerName - Name of the viewer (case-insensitive) + * @param customLogoPath - Optional custom logo filename from config (e.g., "my-logo.png") + * @returns Logo path to use + */ +export function getViewerLogo( + viewerName: string, + customLogoPath?: string, +): string { + const logoFileName = customLogoPath || `${viewerName.toLowerCase()}.png`; + + try { + // Try to dynamically import the logo from assets + // This will be resolved at build time by Vite + const logo = new URL(`../assets/${logoFileName}`, import.meta.url).href; + return logo; + } catch (error) { + // If logo not found, return fallback + return FALLBACK_LOGO; + } +} diff --git a/frontend/src/config/viewersConfig.ts b/frontend/src/config/viewersConfig.ts new file mode 100644 index 000000000..0d9e2bc35 --- /dev/null +++ b/frontend/src/config/viewersConfig.ts @@ -0,0 +1,110 @@ +import yaml from "js-yaml"; + +/** + * Viewer entry from viewers.config.yaml + */ +export interface ViewerConfigEntry { + name: string; + url?: string; + label?: string; + logo?: string; + ome_zarr_versions?: number[]; +} + +/** + * Structure of viewers.config.yaml + */ +export interface ViewersConfigYaml { + viewers: ViewerConfigEntry[]; +} + +/** + * Parse and validate viewers configuration YAML + * @param yamlContent - The YAML content to parse + * @param viewersWithManifests - Array of viewer names that have capability manifests (from initializeViewerManifests) + */ +export function parseViewersConfig( + yamlContent: string, + viewersWithManifests: string[] = [], +): ViewersConfigYaml { + let parsed: unknown; + + try { + parsed = yaml.load(yamlContent); + } catch (error) { + throw new Error( + `Failed to parse viewers configuration YAML: ${error instanceof Error ? error.message : "Unknown error"}`, + ); + } + + if (!parsed || typeof parsed !== "object") { + throw new Error("Configuration must be an object"); + } + + const config = parsed as Record; + + if (!Array.isArray(config.viewers)) { + throw new Error('Configuration must have a "viewers" array'); + } + + // Normalize viewer names for comparison (case-insensitive) + const normalizedManifestViewers = viewersWithManifests.map((name) => + name.toLowerCase(), + ); + + // Validate each viewer entry + for (const viewer of config.viewers) { + if (!viewer || typeof viewer !== "object") { + throw new Error("Each viewer must be an object"); + } + + const v = viewer as Record; + + if (typeof v.name !== "string") { + throw new Error('Each viewer must have a "name" field (string)'); + } + + // Check if this viewer has a capability manifest + const hasManifest = normalizedManifestViewers.includes( + v.name.toLowerCase(), + ); + + // If this viewer doesn't have a capability manifest, require additional fields + if (!hasManifest) { + if (typeof v.url !== "string") { + throw new Error( + `Viewer "${v.name}" does not have a capability manifest and must specify "url"`, + ); + } + if ( + !Array.isArray(v.ome_zarr_versions) || + v.ome_zarr_versions.length === 0 + ) { + throw new Error( + `Viewer "${v.name}" does not have a capability manifest and must specify "ome_zarr_versions" (array of numbers)`, + ); + } + } + + // Validate optional fields if present + if (v.url !== undefined && typeof v.url !== "string") { + throw new Error(`Viewer "${v.name}": "url" must be a string`); + } + if (v.label !== undefined && typeof v.label !== "string") { + throw new Error(`Viewer "${v.name}": "label" must be a string`); + } + if (v.logo !== undefined && typeof v.logo !== "string") { + throw new Error(`Viewer "${v.name}": "logo" must be a string`); + } + if ( + v.ome_zarr_versions !== undefined && + !Array.isArray(v.ome_zarr_versions) + ) { + throw new Error( + `Viewer "${v.name}": "ome_zarr_versions" must be an array`, + ); + } + } + + return config as ViewersConfigYaml; +} From 66204864f5cab863d18139116a826521df50c313 Mon Sep 17 00:00:00 2001 From: allison-truhlar Date: Mon, 2 Feb 2026 11:29:10 -0500 Subject: [PATCH 003/104] feat: add ViewersContext for dynamic viewer configuration --- frontend/src/contexts/ViewersContext.tsx | 234 +++++++++++++++++++++++ frontend/src/layouts/MainLayout.tsx | 41 ++-- 2 files changed, 256 insertions(+), 19 deletions(-) create mode 100644 frontend/src/contexts/ViewersContext.tsx diff --git a/frontend/src/contexts/ViewersContext.tsx b/frontend/src/contexts/ViewersContext.tsx new file mode 100644 index 000000000..cbe0bd14e --- /dev/null +++ b/frontend/src/contexts/ViewersContext.tsx @@ -0,0 +1,234 @@ +import { + createContext, + useContext, + useState, + useEffect, + type ReactNode, +} from "react"; +import { + initializeViewerManifests, + getCompatibleViewers as getCompatibleViewersFromManifest, + type ViewerManifest, + type OmeZarrMetadata, +} from "@bioimagetools/capability-manifest"; +import { default as log } from "@/logger"; +import { + parseViewersConfig, + type ViewerConfigEntry, +} from "@/config/viewersConfig"; +import { getViewerLogo } from "@/config/viewerLogos"; + +/** + * Validated viewer with all necessary information + */ +export interface ValidViewer { + /** Internal key for this viewer (normalized name) */ + key: string; + /** Display name */ + displayName: string; + /** URL template (may contain {dataLink} placeholder) */ + urlTemplate: string; + /** Logo path */ + logoPath: string; + /** Tooltip/alt text label */ + label: string; + /** Associated capability manifest (if available) */ + manifest?: ViewerManifest; + /** Supported OME-Zarr versions (for viewers without manifests) */ + supportedVersions?: number[]; +} + +interface ViewersContextType { + validViewers: ValidViewer[]; + isInitialized: boolean; + error: string | null; + getCompatibleViewers: (metadata: OmeZarrMetadata) => ValidViewer[]; +} + +const ViewersContext = createContext(undefined); + +/** + * Load viewers configuration from build-time config file + * @param viewersWithManifests - Array of viewer names that have capability manifests + */ +async function loadViewersConfig( + viewersWithManifests: string[], +): Promise { + let configYaml: string; + + try { + // Try to dynamically import the config file + // This will be resolved at build time by Vite + const module = await import("@/config/viewers.config.yaml?raw"); + configYaml = module.default; + log.info( + "Using custom viewers configuration from src/config/viewers.config.yaml", + ); + } catch (error) { + log.info( + "No custom viewers.config.yaml found, using default configuration (neuroglancer only)", + ); + // Return default configuration + return [{ name: "neuroglancer" }]; + } + + try { + const config = parseViewersConfig(configYaml, viewersWithManifests); + return config.viewers; + } catch (error) { + log.error("Error parsing viewers configuration:", error); + throw error; + } +} + +/** + * Normalize viewer name to a valid key + */ +function normalizeViewerName(name: string): string { + return name.toLowerCase().replace(/[^a-z0-9]/g, ""); +} + +export function ViewersProvider({ children }: { children: ReactNode }) { + const [validViewers, setValidViewers] = useState([]); + const [isInitialized, setIsInitialized] = useState(false); + const [error, setError] = useState(null); + const [manifests, setManifests] = useState([]); + + useEffect(() => { + async function initialize() { + try { + log.info("Initializing viewers configuration..."); + + // Load capability manifests + let loadedManifests: ViewerManifest[] = []; + try { + loadedManifests = await initializeViewerManifests(); + setManifests(loadedManifests); + log.info( + `Loaded ${loadedManifests.length} viewer capability manifests`, + ); + } catch (manifestError) { + log.warn("Failed to load capability manifests:", manifestError); + } + + const viewersWithManifests = loadedManifests.map((m) => m.viewer.name); + + // Load viewer config entries + const configEntries = await loadViewersConfig(viewersWithManifests); + log.info(`Loaded configuration for ${configEntries.length} viewers`); + + const validated: ValidViewer[] = []; + + // Map through viewer config entries to validate + for (const entry of configEntries) { + const key = normalizeViewerName(entry.name); + const manifest = loadedManifests.find( + (m) => normalizeViewerName(m.viewer.name) === key, + ); + + let urlTemplate: string | undefined = entry.url; + let shouldInclude = true; + let skipReason = ""; + + if (manifest) { + if (!urlTemplate) { + // Use manifest template URL if no override + urlTemplate = manifest.viewer.template_url; + } + + if (!urlTemplate) { + shouldInclude = false; + skipReason = `has capability manifest but no template_url and no URL override in config`; + } + } else { + // No capability manifest + if (!urlTemplate) { + shouldInclude = false; + skipReason = `does not have a capability manifest and no URL provided in config`; + } + } + + if (!shouldInclude) { + log.warn(`Viewer "${entry.name}" excluded: ${skipReason}`); + continue; + } + + // Create valid viewer entry + const displayName = + entry.name.charAt(0).toUpperCase() + entry.name.slice(1); + const label = entry.label || `View in ${displayName}`; + const logoPath = getViewerLogo(entry.name, entry.logo); + + validated.push({ + key, + displayName, + urlTemplate: urlTemplate!, + logoPath, + label, + manifest, + supportedVersions: entry.ome_zarr_versions, + }); + + log.info(`Viewer "${entry.name}" registered successfully`); + } + + if (validated.length === 0) { + throw new Error( + "No valid viewers configured. Check viewers.config.yaml or console for errors.", + ); + } + + setValidViewers(validated); + setIsInitialized(true); + log.info( + `Viewers initialization complete: ${validated.length} viewers available`, + ); + } catch (err) { + const errorMessage = err instanceof Error ? err.message : "Unknown error"; + log.error("Failed to initialize viewers:", errorMessage); + setError(errorMessage); + setIsInitialized(true); // Still mark as initialized to prevent hanging + } + } + + initialize(); + }, []); + + const getCompatibleViewers = (metadata: OmeZarrMetadata): ValidViewer[] => { + if (!isInitialized || !metadata) { + return []; + } + + return validViewers.filter((viewer) => { + if (viewer.manifest) { + const compatibleNames = getCompatibleViewersFromManifest(metadata); + return compatibleNames.includes(viewer.manifest.viewer.name); + } else { + // Manual version check for viewers without manifests + const zarrVersion = metadata.version + ? parseFloat(metadata.version) + : null; + if (zarrVersion === null || !viewer.supportedVersions) { + return false; + } + return viewer.supportedVersions.includes(zarrVersion); + } + }); + }; + + return ( + + {children} + + ); +} + +export function useViewersContext() { + const context = useContext(ViewersContext); + if (!context) { + throw new Error("useViewersContext must be used within ViewersProvider"); + } + return context; +} diff --git a/frontend/src/layouts/MainLayout.tsx b/frontend/src/layouts/MainLayout.tsx index 88d0b33c1..09306c415 100644 --- a/frontend/src/layouts/MainLayout.tsx +++ b/frontend/src/layouts/MainLayout.tsx @@ -18,6 +18,7 @@ import { ExternalBucketProvider } from '@/contexts/ExternalBucketContext'; import { ProfileContextProvider } from '@/contexts/ProfileContext'; import { NotificationProvider } from '@/contexts/NotificationsContext'; import { ServerHealthProvider } from '@/contexts/ServerHealthContext'; +import { ViewersProvider } from '@/contexts/ViewersContext'; import FileglancerNavbar from '@/components/ui/Navbar/Navbar'; import Notifications from '@/components/ui/Notifications/Notifications'; import ErrorFallback from '@/components/ErrorFallback'; @@ -64,25 +65,27 @@ export const MainLayout = () => { return ( - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + ); }; From 24e030e2fcb5b9f57462e09d1b3781102b77d244 Mon Sep 17 00:00:00 2001 From: allison-truhlar Date: Mon, 2 Feb 2026 11:34:06 -0500 Subject: [PATCH 004/104] refactor: make OpenWithToolUrls dynamic using Record type --- frontend/package-lock.json | 20 ++++++++++++++++++-- frontend/package.json | 3 +++ frontend/src/config/viewersConfig.ts | 2 +- frontend/src/queries/zarrQueries.ts | 10 +++++----- 4 files changed, 27 insertions(+), 8 deletions(-) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index c322152a8..d79efbeb8 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -9,12 +9,15 @@ "version": "2.4.0", "license": "BSD-3-Clause", "dependencies": { + "@bioimagetools/capability-manifest": "^0.2.0", "@material-tailwind/react": "^3.0.0-beta.24", "@tanstack/react-query": "^5.90.2", "@tanstack/react-table": "^8.21.3", + "@types/js-yaml": "^4.0.9", "@types/react-syntax-highlighter": "^15.5.13", "@vitejs/plugin-react": "^5.0.0", "autoprefixer": "^10.4.21", + "js-yaml": "^4.1.1", "loglevel": "^1.9.2", "npm-run-all2": "^7.0.2", "ome-zarr.js": "^0.0.17", @@ -398,6 +401,15 @@ "node": ">=18" } }, + "node_modules/@bioimagetools/capability-manifest": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/@bioimagetools/capability-manifest/-/capability-manifest-0.2.0.tgz", + "integrity": "sha512-eZa4DmOCxbxS6BN8aHOqPNrfCP/zWMigvVwCY3aNTlLTsG3ZQ2Ap/TJetY7qnagNl3sIXYHiHbKDhBYNee5rDw==", + "license": "ISC", + "dependencies": { + "js-yaml": "^4.1.1" + } + }, "node_modules/@emnapi/core": { "version": "1.7.1", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.7.1.tgz", @@ -1825,6 +1837,12 @@ "@types/unist": "*" } }, + "node_modules/@types/js-yaml": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/js-yaml/-/js-yaml-4.0.9.tgz", + "integrity": "sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==", + "license": "MIT" + }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -2437,7 +2455,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true, "license": "Python-2.0" }, "node_modules/aria-query": { @@ -5863,7 +5880,6 @@ "version": "4.1.1", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", - "dev": true, "license": "MIT", "dependencies": { "argparse": "^2.0.1" diff --git a/frontend/package.json b/frontend/package.json index 12a3c0ba7..6810c720b 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -30,12 +30,15 @@ "test": "vitest" }, "dependencies": { + "@bioimagetools/capability-manifest": "^0.2.0", "@material-tailwind/react": "^3.0.0-beta.24", "@tanstack/react-query": "^5.90.2", "@tanstack/react-table": "^8.21.3", + "@types/js-yaml": "^4.0.9", "@types/react-syntax-highlighter": "^15.5.13", "@vitejs/plugin-react": "^5.0.0", "autoprefixer": "^10.4.21", + "js-yaml": "^4.1.1", "loglevel": "^1.9.2", "npm-run-all2": "^7.0.2", "ome-zarr.js": "^0.0.17", diff --git a/frontend/src/config/viewersConfig.ts b/frontend/src/config/viewersConfig.ts index 0d9e2bc35..a7bd21354 100644 --- a/frontend/src/config/viewersConfig.ts +++ b/frontend/src/config/viewersConfig.ts @@ -106,5 +106,5 @@ export function parseViewersConfig( } } - return config as ViewersConfigYaml; + return config as unknown as ViewersConfigYaml; } diff --git a/frontend/src/queries/zarrQueries.ts b/frontend/src/queries/zarrQueries.ts index 67f71de2a..5efef7b98 100644 --- a/frontend/src/queries/zarrQueries.ts +++ b/frontend/src/queries/zarrQueries.ts @@ -12,11 +12,11 @@ import { FileOrFolder } from '@/shared.types'; export type OpenWithToolUrls = { copy: string; - validator: string | null; - neuroglancer: string; - vole: string | null; - avivator: string | null; -}; +} & Record; + +// The 'copy' key is always present, all other keys are viewer-specific +// null means the viewer is incompatible with this dataset +// empty string means the viewer is compatible but no data URL is available yet export type ZarrMetadata = Metadata | null; From 9f5d1ebf20bb9cca785837b7a4b3e8ace7a7ec46 Mon Sep 17 00:00:00 2001 From: allison-truhlar Date: Mon, 2 Feb 2026 11:40:32 -0500 Subject: [PATCH 005/104] docs: add comment explaining type assertion in parseViewersConfig --- frontend/src/config/viewersConfig.ts | 39 +++++++++++++++------------- 1 file changed, 21 insertions(+), 18 deletions(-) diff --git a/frontend/src/config/viewersConfig.ts b/frontend/src/config/viewersConfig.ts index a7bd21354..6237c913b 100644 --- a/frontend/src/config/viewersConfig.ts +++ b/frontend/src/config/viewersConfig.ts @@ -1,4 +1,4 @@ -import yaml from "js-yaml"; +import yaml from 'js-yaml'; /** * Viewer entry from viewers.config.yaml @@ -25,7 +25,7 @@ export interface ViewersConfigYaml { */ export function parseViewersConfig( yamlContent: string, - viewersWithManifests: string[] = [], + viewersWithManifests: string[] = [] ): ViewersConfigYaml { let parsed: unknown; @@ -33,12 +33,12 @@ export function parseViewersConfig( parsed = yaml.load(yamlContent); } catch (error) { throw new Error( - `Failed to parse viewers configuration YAML: ${error instanceof Error ? error.message : "Unknown error"}`, + `Failed to parse viewers configuration YAML: ${error instanceof Error ? error.message : 'Unknown error'}` ); } - if (!parsed || typeof parsed !== "object") { - throw new Error("Configuration must be an object"); + if (!parsed || typeof parsed !== 'object') { + throw new Error('Configuration must be an object'); } const config = parsed as Record; @@ -48,32 +48,32 @@ export function parseViewersConfig( } // Normalize viewer names for comparison (case-insensitive) - const normalizedManifestViewers = viewersWithManifests.map((name) => - name.toLowerCase(), + const normalizedManifestViewers = viewersWithManifests.map(name => + name.toLowerCase() ); // Validate each viewer entry for (const viewer of config.viewers) { - if (!viewer || typeof viewer !== "object") { - throw new Error("Each viewer must be an object"); + if (!viewer || typeof viewer !== 'object') { + throw new Error('Each viewer must be an object'); } const v = viewer as Record; - if (typeof v.name !== "string") { + if (typeof v.name !== 'string') { throw new Error('Each viewer must have a "name" field (string)'); } // Check if this viewer has a capability manifest const hasManifest = normalizedManifestViewers.includes( - v.name.toLowerCase(), + v.name.toLowerCase() ); // If this viewer doesn't have a capability manifest, require additional fields if (!hasManifest) { - if (typeof v.url !== "string") { + if (typeof v.url !== 'string') { throw new Error( - `Viewer "${v.name}" does not have a capability manifest and must specify "url"`, + `Viewer "${v.name}" does not have a capability manifest and must specify "url"` ); } if ( @@ -81,19 +81,19 @@ export function parseViewersConfig( v.ome_zarr_versions.length === 0 ) { throw new Error( - `Viewer "${v.name}" does not have a capability manifest and must specify "ome_zarr_versions" (array of numbers)`, + `Viewer "${v.name}" does not have a capability manifest and must specify "ome_zarr_versions" (array of numbers)` ); } } // Validate optional fields if present - if (v.url !== undefined && typeof v.url !== "string") { + if (v.url !== undefined && typeof v.url !== 'string') { throw new Error(`Viewer "${v.name}": "url" must be a string`); } - if (v.label !== undefined && typeof v.label !== "string") { + if (v.label !== undefined && typeof v.label !== 'string') { throw new Error(`Viewer "${v.name}": "label" must be a string`); } - if (v.logo !== undefined && typeof v.logo !== "string") { + if (v.logo !== undefined && typeof v.logo !== 'string') { throw new Error(`Viewer "${v.name}": "logo" must be a string`); } if ( @@ -101,10 +101,13 @@ export function parseViewersConfig( !Array.isArray(v.ome_zarr_versions) ) { throw new Error( - `Viewer "${v.name}": "ome_zarr_versions" must be an array`, + `Viewer "${v.name}": "ome_zarr_versions" must be an array` ); } } + // Type assertion is safe here because we've performed comprehensive runtime validation above. + // TypeScript sees 'config' as Record but our validation ensures it matches + // ViewersConfigYaml structure. The intermediate 'unknown' cast is required for type compatibility. return config as unknown as ViewersConfigYaml; } From 28173eafdbc478dccbeb8a933d29ece679a9f6ba Mon Sep 17 00:00:00 2001 From: allison-truhlar Date: Mon, 2 Feb 2026 11:42:28 -0500 Subject: [PATCH 006/104] refactor: use ViewersContext in useZarrMetadata for dynamic viewer URLs --- frontend/src/hooks/useZarrMetadata.ts | 180 +++++++++++++++----------- 1 file changed, 101 insertions(+), 79 deletions(-) diff --git a/frontend/src/hooks/useZarrMetadata.ts b/frontend/src/hooks/useZarrMetadata.ts index 3df572883..d009c0a04 100644 --- a/frontend/src/hooks/useZarrMetadata.ts +++ b/frontend/src/hooks/useZarrMetadata.ts @@ -4,6 +4,7 @@ import { useFileBrowserContext } from '@/contexts/FileBrowserContext'; import { usePreferencesContext } from '@/contexts/PreferencesContext'; import { useProxiedPathContext } from '@/contexts/ProxiedPathContext'; import { useExternalBucketContext } from '@/contexts/ExternalBucketContext'; +import { useViewersContext } from '@/contexts/ViewersContext'; import { useZarrMetadataQuery, useOmeZarrThumbnailQuery @@ -31,6 +32,11 @@ export default function useZarrMetadata() { disableHeuristicalLayerTypeDetection, useLegacyMultichannelApproach } = usePreferencesContext(); + const { + validViewers, + isInitialized: viewersInitialized, + getCompatibleViewers + } = useViewersContext(); // Fetch Zarr metadata const zarrMetadataQuery = useZarrMetadataQuery({ @@ -88,103 +94,116 @@ export default function useZarrMetadata() { }, [thumbnailSrc, disableHeuristicalLayerTypeDetection]); const openWithToolUrls = useMemo(() => { - if (!metadata) { + if (!metadata || !viewersInitialized) { return null; } - const validatorBaseUrl = 'https://ome.github.io/ome-ngff-validator/'; - const neuroglancerBaseUrl = 'https://neuroglancer-demo.appspot.com/#!'; - const voleBaseUrl = 'https://volumeviewer.allencell.org/viewer'; - const avivatorBaseUrl = 'https://janeliascicomp.github.io/viv/'; const url = externalDataUrlQuery.data || proxiedPathByFspAndPathQuery.data?.url; + const openWithToolUrls = { copy: url || '' } as OpenWithToolUrls; - // Determine which tools should be available based on metadata type - if (metadata?.multiscale) { - // OME-Zarr - all urls for v2; no avivator for v3 - if (url) { - if (effectiveZarrVersion === 2) { - openWithToolUrls.avivator = buildUrl(avivatorBaseUrl, null, { - image_url: url - }); + // Get compatible viewers for this dataset + let compatibleViewers = validViewers; + + // If we have metadata, use capability checking to filter + if (metadata) { + // Convert our metadata to OmeZarrMetadata format for capability checking + const omeZarrMetadata = { + version: effectiveZarrVersion === 3 ? '0.5' : '0.4', + axes: metadata.multiscale?.axes, + multiscales: metadata.multiscale ? [metadata.multiscale] : undefined, + omero: metadata.omero, + labels: metadata.labels + } as any; // Type assertion needed due to internal type differences + + compatibleViewers = getCompatibleViewers(omeZarrMetadata); + } + + // Create a Set for lookup of compatible viewer keys + // Needed to mark incompatible but valid (as defined by the viewer config) viewers as null in openWithToolUrls + const compatibleKeys = new Set(compatibleViewers.map(v => v.key)); + + for (const viewer of validViewers) { + if (!compatibleKeys.has(viewer.key)) { + openWithToolUrls[viewer.key] = null; + } + } + + // For compatible viewers, generate URLs + for (const viewer of compatibleViewers) { + if (!url) { + // Compatible but no data URL yet - show as available (empty string) + openWithToolUrls[viewer.key] = ''; + continue; + } + + // Generate the viewer URL + let viewerUrl = viewer.urlTemplate; + + // Special handling for Neuroglancer to maintain existing state generation logic + if (viewer.key === 'neuroglancer') { + const neuroglancerBaseUrl = viewer.urlTemplate; + + if (metadata?.multiscale) { + // OME-Zarr with multiscales + if (disableNeuroglancerStateGeneration) { + viewerUrl = + neuroglancerBaseUrl + + generateNeuroglancerStateForDataURL(url, effectiveZarrVersion); + } else if (layerType) { + try { + viewerUrl = + neuroglancerBaseUrl + + generateNeuroglancerStateForOmeZarr( + url, + effectiveZarrVersion, + layerType, + metadata.multiscale, + metadata.arr, + metadata.labels, + metadata.omero, + useLegacyMultichannelApproach + ); + } catch (error) { + log.error( + 'Error generating Neuroglancer state for OME-Zarr:', + error + ); + viewerUrl = + neuroglancerBaseUrl + + generateNeuroglancerStateForDataURL(url, effectiveZarrVersion); + } + } } else { - openWithToolUrls.avivator = null; - } - // Populate with actual URLs when proxied path is available - openWithToolUrls.validator = buildUrl(validatorBaseUrl, null, { - source: url - }); - openWithToolUrls.vole = buildUrl(voleBaseUrl, null, { - url - }); - if (disableNeuroglancerStateGeneration) { - openWithToolUrls.neuroglancer = - neuroglancerBaseUrl + - generateNeuroglancerStateForDataURL(url, effectiveZarrVersion); - } else if (layerType) { - try { - openWithToolUrls.neuroglancer = + // Non-OME Zarr array + if (disableNeuroglancerStateGeneration) { + viewerUrl = + neuroglancerBaseUrl + + generateNeuroglancerStateForDataURL(url, effectiveZarrVersion); + } else if (layerType) { + viewerUrl = neuroglancerBaseUrl + - generateNeuroglancerStateForOmeZarr( + generateNeuroglancerStateForZarrArray( url, effectiveZarrVersion, - layerType, - metadata.multiscale, - metadata.arr, - metadata.labels, - metadata.omero, - useLegacyMultichannelApproach + layerType ); - } catch (error) { - log.error( - 'Error generating Neuroglancer state for OME-Zarr:', - error - ); - openWithToolUrls.neuroglancer = - neuroglancerBaseUrl + - generateNeuroglancerStateForDataURL(url, effectiveZarrVersion); } } } else { - // No proxied URL - show all tools as available but empty - openWithToolUrls.validator = ''; - openWithToolUrls.vole = ''; - // if this is a zarr version 2, then set the url to blank which will show - // the icon before a data link has been generated. Setting it to null for - // all other versions, eg zarr v3 means the icon will not be present before - // a data link is generated. - openWithToolUrls.avivator = effectiveZarrVersion === 2 ? '' : null; - openWithToolUrls.neuroglancer = ''; - } - } else { - // Non-OME Zarr - only Neuroglancer available - if (url) { - openWithToolUrls.validator = null; - openWithToolUrls.vole = null; - openWithToolUrls.avivator = null; - if (disableNeuroglancerStateGeneration) { - openWithToolUrls.neuroglancer = - neuroglancerBaseUrl + - generateNeuroglancerStateForDataURL(url, effectiveZarrVersion); - } else if (layerType) { - openWithToolUrls.neuroglancer = - neuroglancerBaseUrl + - generateNeuroglancerStateForZarrArray( - url, - effectiveZarrVersion, - layerType - ); + // For other viewers, replace {dataLink} placeholder if present + if (viewerUrl.includes('{dataLink}')) { + viewerUrl = viewerUrl.replace(/{dataLink}/g, encodeURIComponent(url)); + } else { + // If no placeholder, use buildUrl with 'url' query param + viewerUrl = buildUrl(viewerUrl, null, { url }); } - } else { - // No proxied URL - only show Neuroglancer as available but empty - openWithToolUrls.validator = null; - openWithToolUrls.vole = null; - openWithToolUrls.avivator = null; - openWithToolUrls.neuroglancer = ''; } + + openWithToolUrls[viewer.key] = viewerUrl; } return openWithToolUrls; @@ -195,7 +214,10 @@ export default function useZarrMetadata() { disableNeuroglancerStateGeneration, useLegacyMultichannelApproach, layerType, - effectiveZarrVersion + effectiveZarrVersion, + validViewers, + viewersInitialized, + getCompatibleViewers ]); return { From 3fd00d7ec2f3dd6a7252c7b225097927bcd2570a Mon Sep 17 00:00:00 2001 From: allison-truhlar Date: Mon, 2 Feb 2026 11:43:09 -0500 Subject: [PATCH 007/104] refactor: use ViewersContext in DataToolLinks for dynamic viewer icons --- .../ui/BrowsePage/DataToolLinks.tsx | 141 +++++------------- 1 file changed, 39 insertions(+), 102 deletions(-) diff --git a/frontend/src/components/ui/BrowsePage/DataToolLinks.tsx b/frontend/src/components/ui/BrowsePage/DataToolLinks.tsx index b4e34d0c0..d5c8b4f71 100644 --- a/frontend/src/components/ui/BrowsePage/DataToolLinks.tsx +++ b/frontend/src/components/ui/BrowsePage/DataToolLinks.tsx @@ -1,13 +1,12 @@ import { Button, ButtonGroup, Typography } from '@material-tailwind/react'; import { Link } from 'react-router'; - -import neuroglancer_logo from '@/assets/neuroglancer.png'; -import validator_logo from '@/assets/ome-ngff-validator.png'; -import volE_logo from '@/assets/aics_website-3d-cell-viewer.png'; -import avivator_logo from '@/assets/vizarr_logo.png'; import copy_logo from '@/assets/copy-link-64.png'; -import type { OpenWithToolUrls, PendingToolKey } from '@/hooks/useZarrMetadata'; +import type { + OpenWithToolUrls, + PendingToolKey +} from '@/hooks/useZarrMetadata'; import FgTooltip from '@/components/ui/widgets/FgTooltip'; +import { useViewersContext } from '@/contexts/ViewersContext'; export default function DataToolLinks({ onToolClick, @@ -20,6 +19,8 @@ export default function DataToolLinks({ readonly title: string; readonly urls: OpenWithToolUrls | null; }) { + const { validViewers } = useViewersContext(); + const tooltipTriggerClasses = 'rounded-sm m-0 p-0 transform active:scale-90 transition-transform duration-75'; @@ -33,106 +34,42 @@ export default function DataToolLinks({ {title} - {urls.neuroglancer !== null ? ( - - { - e.preventDefault(); - await onToolClick('neuroglancer'); - }} - rel="noopener noreferrer" - target="_blank" - to={urls.neuroglancer} - > - Neuroglancer logo - - - ) : null} + {validViewers.map(viewer => { + const url = urls[viewer.key]; - {urls.vole !== null ? ( - - { - e.preventDefault(); - await onToolClick('vole'); - }} - rel="noopener noreferrer" - target="_blank" - to={urls.vole} - > - Vol-E logo - - - ) : null} - - {urls.avivator !== null ? ( - - { - e.preventDefault(); - await onToolClick('avivator'); - }} - rel="noopener noreferrer" - target="_blank" - to={urls.avivator} - > - Avivator logo - - - ) : null} + // null means incompatible, don't show + if (url === null) { + return null; + } - {urls.validator !== null ? ( - - { - e.preventDefault(); - await onToolClick('validator'); - }} - rel="noopener noreferrer" - target="_blank" - to={urls.validator} + return ( + - OME-Zarr Validator logo - - - ) : null} + { + e.preventDefault(); + await onToolClick(viewer.key as PendingToolKey); + }} + rel="noopener noreferrer" + target="_blank" + to={url} + > + {viewer.label} + + + ); + })} + {/* Copy URL tool - always available when there's a data URL */} Date: Mon, 2 Feb 2026 11:59:26 -0500 Subject: [PATCH 008/104] chore: prettier formatting --- .../ui/BrowsePage/DataToolLinks.tsx | 7 +-- frontend/src/config/viewerLogos.ts | 5 +- frontend/src/contexts/ViewersContext.tsx | 57 ++++++++++--------- 3 files changed, 34 insertions(+), 35 deletions(-) diff --git a/frontend/src/components/ui/BrowsePage/DataToolLinks.tsx b/frontend/src/components/ui/BrowsePage/DataToolLinks.tsx index d5c8b4f71..978939bf6 100644 --- a/frontend/src/components/ui/BrowsePage/DataToolLinks.tsx +++ b/frontend/src/components/ui/BrowsePage/DataToolLinks.tsx @@ -1,10 +1,7 @@ import { Button, ButtonGroup, Typography } from '@material-tailwind/react'; import { Link } from 'react-router'; import copy_logo from '@/assets/copy-link-64.png'; -import type { - OpenWithToolUrls, - PendingToolKey -} from '@/hooks/useZarrMetadata'; +import type { OpenWithToolUrls, PendingToolKey } from '@/hooks/useZarrMetadata'; import FgTooltip from '@/components/ui/widgets/FgTooltip'; import { useViewersContext } from '@/contexts/ViewersContext'; @@ -44,8 +41,8 @@ export default function DataToolLinks({ return ( (undefined); * @param viewersWithManifests - Array of viewer names that have capability manifests */ async function loadViewersConfig( - viewersWithManifests: string[], + viewersWithManifests: string[] ): Promise { let configYaml: string; try { // Try to dynamically import the config file // This will be resolved at build time by Vite - const module = await import("@/config/viewers.config.yaml?raw"); + const module = await import('@/config/viewers.config.yaml?raw'); configYaml = module.default; log.info( - "Using custom viewers configuration from src/config/viewers.config.yaml", + 'Using custom viewers configuration from src/config/viewers.config.yaml' ); } catch (error) { log.info( - "No custom viewers.config.yaml found, using default configuration (neuroglancer only)", + 'No custom viewers.config.yaml found, using default configuration (neuroglancer only)' ); // Return default configuration - return [{ name: "neuroglancer" }]; + return [{ name: 'neuroglancer' }]; } try { const config = parseViewersConfig(configYaml, viewersWithManifests); return config.viewers; } catch (error) { - log.error("Error parsing viewers configuration:", error); + log.error('Error parsing viewers configuration:', error); throw error; } } @@ -85,7 +85,7 @@ async function loadViewersConfig( * Normalize viewer name to a valid key */ function normalizeViewerName(name: string): string { - return name.toLowerCase().replace(/[^a-z0-9]/g, ""); + return name.toLowerCase().replace(/[^a-z0-9]/g, ''); } export function ViewersProvider({ children }: { children: ReactNode }) { @@ -97,7 +97,7 @@ export function ViewersProvider({ children }: { children: ReactNode }) { useEffect(() => { async function initialize() { try { - log.info("Initializing viewers configuration..."); + log.info('Initializing viewers configuration...'); // Load capability manifests let loadedManifests: ViewerManifest[] = []; @@ -105,13 +105,13 @@ export function ViewersProvider({ children }: { children: ReactNode }) { loadedManifests = await initializeViewerManifests(); setManifests(loadedManifests); log.info( - `Loaded ${loadedManifests.length} viewer capability manifests`, + `Loaded ${loadedManifests.length} viewer capability manifests` ); } catch (manifestError) { - log.warn("Failed to load capability manifests:", manifestError); + log.warn('Failed to load capability manifests:', manifestError); } - const viewersWithManifests = loadedManifests.map((m) => m.viewer.name); + const viewersWithManifests = loadedManifests.map(m => m.viewer.name); // Load viewer config entries const configEntries = await loadViewersConfig(viewersWithManifests); @@ -123,12 +123,12 @@ export function ViewersProvider({ children }: { children: ReactNode }) { for (const entry of configEntries) { const key = normalizeViewerName(entry.name); const manifest = loadedManifests.find( - (m) => normalizeViewerName(m.viewer.name) === key, + m => normalizeViewerName(m.viewer.name) === key ); let urlTemplate: string | undefined = entry.url; let shouldInclude = true; - let skipReason = ""; + let skipReason = ''; if (manifest) { if (!urlTemplate) { @@ -166,7 +166,7 @@ export function ViewersProvider({ children }: { children: ReactNode }) { logoPath, label, manifest, - supportedVersions: entry.ome_zarr_versions, + supportedVersions: entry.ome_zarr_versions }); log.info(`Viewer "${entry.name}" registered successfully`); @@ -174,18 +174,19 @@ export function ViewersProvider({ children }: { children: ReactNode }) { if (validated.length === 0) { throw new Error( - "No valid viewers configured. Check viewers.config.yaml or console for errors.", + 'No valid viewers configured. Check viewers.config.yaml or console for errors.' ); } setValidViewers(validated); setIsInitialized(true); log.info( - `Viewers initialization complete: ${validated.length} viewers available`, + `Viewers initialization complete: ${validated.length} viewers available` ); } catch (err) { - const errorMessage = err instanceof Error ? err.message : "Unknown error"; - log.error("Failed to initialize viewers:", errorMessage); + const errorMessage = + err instanceof Error ? err.message : 'Unknown error'; + log.error('Failed to initialize viewers:', errorMessage); setError(errorMessage); setIsInitialized(true); // Still mark as initialized to prevent hanging } @@ -199,7 +200,7 @@ export function ViewersProvider({ children }: { children: ReactNode }) { return []; } - return validViewers.filter((viewer) => { + return validViewers.filter(viewer => { if (viewer.manifest) { const compatibleNames = getCompatibleViewersFromManifest(metadata); return compatibleNames.includes(viewer.manifest.viewer.name); @@ -228,7 +229,7 @@ export function ViewersProvider({ children }: { children: ReactNode }) { export function useViewersContext() { const context = useContext(ViewersContext); if (!context) { - throw new Error("useViewersContext must be used within ViewersProvider"); + throw new Error('useViewersContext must be used within ViewersProvider'); } return context; } From ecf1b625b5d1902e6da4c94f2b5b546014c460d7 Mon Sep 17 00:00:00 2001 From: allison-truhlar Date: Mon, 2 Feb 2026 11:59:46 -0500 Subject: [PATCH 009/104] chore: update logo names; add fallback logo --- .../src/assets/{vizarr_logo.png => avivator.png} | Bin frontend/src/assets/fallback_logo.png | Bin 0 -> 8198 bytes .../{ome-ngff-validator.png => validator.png} | Bin ...{aics_website-3d-cell-viewer.png => vole.png} | Bin 4 files changed, 0 insertions(+), 0 deletions(-) rename frontend/src/assets/{vizarr_logo.png => avivator.png} (100%) create mode 100644 frontend/src/assets/fallback_logo.png rename frontend/src/assets/{ome-ngff-validator.png => validator.png} (100%) rename frontend/src/assets/{aics_website-3d-cell-viewer.png => vole.png} (100%) diff --git a/frontend/src/assets/vizarr_logo.png b/frontend/src/assets/avivator.png similarity index 100% rename from frontend/src/assets/vizarr_logo.png rename to frontend/src/assets/avivator.png diff --git a/frontend/src/assets/fallback_logo.png b/frontend/src/assets/fallback_logo.png new file mode 100644 index 0000000000000000000000000000000000000000..f636cabbcebaf46d99572dd5c77da3abe3d99525 GIT binary patch literal 8198 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 literal 0 HcmV?d00001 diff --git a/frontend/src/assets/ome-ngff-validator.png b/frontend/src/assets/validator.png similarity index 100% rename from frontend/src/assets/ome-ngff-validator.png rename to frontend/src/assets/validator.png diff --git a/frontend/src/assets/aics_website-3d-cell-viewer.png b/frontend/src/assets/vole.png similarity index 100% rename from frontend/src/assets/aics_website-3d-cell-viewer.png rename to frontend/src/assets/vole.png From 7698348060a96f3ca814673a76ca8536057b6fed Mon Sep 17 00:00:00 2001 From: allison-truhlar Date: Mon, 2 Feb 2026 12:05:26 -0500 Subject: [PATCH 010/104] docs: add viewers configuration guide --- docs/Development.md | 19 +++++ docs/ViewersConfiguration.md | 150 +++++++++++++++++++++++++++++++++++ 2 files changed, 169 insertions(+) create mode 100644 docs/ViewersConfiguration.md diff --git a/docs/Development.md b/docs/Development.md index f39aef1e9..0d82c3f3b 100644 --- a/docs/Development.md +++ b/docs/Development.md @@ -66,6 +66,25 @@ file_share_mounts: Instead of using the `file_share_mounts` setting, you can configure file share paths in the database. This is useful for production deployments where you want centralized management of file share paths. To use the paths in the database, set `file_share_mounts: []`. See [fileglancer-janelia](https://github.com/JaneliaSciComp/fileglancer-janelia) for an example of populating the file share paths in the database, using a private wiki source. +### Viewers Configuration + +Fileglancer supports dynamic configuration of OME-Zarr viewers through `viewers.config.yaml`. This allows you to customize which viewers are available in your deployment and configure custom viewer URLs. + +**Quick Setup:** + +1. Copy the template to the config directory: + ```bash + cp docs/viewers.config.yaml.template frontend/src/config/viewers.config.yaml + ``` + +2. Edit `frontend/src/config/viewers.config.yaml` to enable/disable viewers or customize URLs + +3. Rebuild the application: `pixi run node-build` or use watch mode in development: `pixi run dev-watch` + +**Note:** The configuration file is bundled at build time, so changes require rebuilding the application. + +For detailed configuration options, examples, and documentation on adding custom viewers, see [ViewersConfiguration.md](ViewersConfiguration.md). + ### Running with SSL/HTTPS (Secure Mode) By default, `pixi run dev-launch` runs the server in insecure HTTP mode on port 7878. This is suitable for most local development scenarios. diff --git a/docs/ViewersConfiguration.md b/docs/ViewersConfiguration.md new file mode 100644 index 000000000..6fd7f0cb7 --- /dev/null +++ b/docs/ViewersConfiguration.md @@ -0,0 +1,150 @@ +# Viewers Configuration Guide + +Fileglancer supports dynamic configuration of OME-Zarr viewers. This allows administrators to customize which viewers are available in their deployment and configure custom viewer URLs. + +## Overview + +The viewer system uses: + +- **viewers.config.yaml**: User configuration file defining available viewers +- **@bioimagetools/capability-manifest**: Library for automatic compatibility detection +- **ViewersContext**: React context providing viewer information to the application + +## Quick Start + +1. Copy the template to the config directory: + +```bash +cp docs/viewers.config.yaml.template frontend/src/config/viewers.config.yaml +``` + +2. Edit `frontend/src/config/viewers.config.yaml` to enable/disable viewers or customize URLs + +3. Build the application - configuration is bundled at build time + +## Configuration File Location + +Place `viewers.config.yaml` in `frontend/src/config/` directory. + +**Important:** This file is bundled at build time. Changes require rebuilding the application. + +If no configuration file exists, Fileglancer defaults to Neuroglancer only. + +## Viewer Types + +### Viewers with Capability Manifests (Recommended) + +These viewers have metadata describing their capabilities, allowing automatic compatibility detection. For example, Neuroglancer and Avivator. For these viewers, you only need to specify the name. URL and compatibility are handled automatically. + +### Custom Viewers + +For viewers without capability manifests, you must provide: + +- `name`: Viewer identifier +- `url`: URL template (use `{dataLink}` placeholder for dataset URL) +- `ome_zarr_versions`: Array of supported OME-Zarr versions (e.g., `[0.4, 0.5]`) + +Optionally: + +- `logo`: Filename of logo in `frontend/src/assets/` (defaults to `{name}.png` if not specified) +- `label`: Custom tooltip text (defaults to "View in {Name}") + +## Configuration Examples + +### Enable default viewers + +```yaml +viewers: + - name: neuroglancer + - name: avivator +``` + +### Override viewer URL + +```yaml +viewers: + - name: avivator + url: "https://my-avivator-instance.example.com/?image_url={dataLink}" +``` + +### Add custom viewer (with convention-based logo) + +```yaml +viewers: + - name: my-viewer + url: "https://viewer.example.com/?data={dataLink}" + ome_zarr_versions: [0.4, 0.5] + # Logo will automatically resolve to @/assets/my-viewer.png + label: "Open in My Viewer" +``` + +### Add custom viewer (with explicit logo) + +```yaml +viewers: + - name: my-viewer + url: "https://viewer.example.com/?data={dataLink}" + ome_zarr_versions: [0.4, 0.5] + logo: "custom-logo.png" # Use @/assets/custom-logo.png + label: "Open in My Viewer" +``` + +## Adding Custom Viewer Logos + +Logo resolution follows this order: + +1. **Custom logo specified**: If you provide a `logo` field in the config, it will be used +2. **Convention-based**: If no logo is specified, the system looks for `@/assets/{name}.png` +3. **Fallback**: If neither exists, uses `@/assets/fallback_logo.png` + +### Examples: + +**Using the naming convention (recommended):** + +```yaml +viewers: + - name: my-viewer + # Logo will automatically resolve to @/assets/my-viewer.png +``` + +Just add `frontend/src/assets/my-viewer.png` - no config needed! + +**Using a custom logo filename:** + +```yaml +viewers: + - name: my-viewer + logo: "custom-logo.png" # Will use @/assets/custom-logo.png +``` + +## How Compatibility Works + +### For Viewers with Manifests + +The @bioimagetools/capability-manifest library checks: + +- OME-Zarr version support +- Axis types and configurations +- Compression codecs +- Special features (labels, HCS plates, etc.) + +### For Custom Viewers + +Simple version matching: + +- Dataset version is compared against `ome_zarr_versions` list +- Viewer is shown only if version matches + +## Development + +When developing with custom configurations: + +1. Create/edit `frontend/src/config/viewers.config.yaml` +2. Rebuild frontend: `pixi run node-build` or use watch mode: `pixi run dev-watch` +3. Check console for initialization messages + +**Note:** The config file is gitignored to allow per-deployment customization without committing changes. + +## Copy URL Tool + +The "Copy data URL" tool is always available when a data URL exists, regardless of viewer configuration. From 69ae1b67068ab63dc4314fda90ca5a2b6c0e4c5c Mon Sep 17 00:00:00 2001 From: allison-truhlar Date: Mon, 2 Feb 2026 12:39:22 -0500 Subject: [PATCH 011/104] fix: require OME metadata for custom viewers without manifests - Custom viewers (validator, vol-e) now check for multiscales - Ensures viewers only display for OME-Zarr datasets, not plain Zarr arrays - Update DataToolLinks alt text to use displayName for E2E test compatibility --- frontend/src/components/ui/BrowsePage/DataToolLinks.tsx | 2 +- frontend/src/contexts/ViewersContext.tsx | 8 ++++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/frontend/src/components/ui/BrowsePage/DataToolLinks.tsx b/frontend/src/components/ui/BrowsePage/DataToolLinks.tsx index 978939bf6..68f4b4091 100644 --- a/frontend/src/components/ui/BrowsePage/DataToolLinks.tsx +++ b/frontend/src/components/ui/BrowsePage/DataToolLinks.tsx @@ -57,7 +57,7 @@ export default function DataToolLinks({ to={url} > {viewer.label} diff --git a/frontend/src/contexts/ViewersContext.tsx b/frontend/src/contexts/ViewersContext.tsx index db828fc60..51a43176e 100644 --- a/frontend/src/contexts/ViewersContext.tsx +++ b/frontend/src/contexts/ViewersContext.tsx @@ -206,12 +206,20 @@ export function ViewersProvider({ children }: { children: ReactNode }) { return compatibleNames.includes(viewer.manifest.viewer.name); } else { // Manual version check for viewers without manifests + // Custom viewers require both correct ome-zarr version AND OME metadata (multiscales) const zarrVersion = metadata.version ? parseFloat(metadata.version) : null; if (zarrVersion === null || !viewer.supportedVersions) { return false; } + + // Check if dataset has OME metadata (multiscales array) + const hasOmeMetadata = metadata.multiscales && metadata.multiscales.length > 0; + if (!hasOmeMetadata) { + return false; + } + return viewer.supportedVersions.includes(zarrVersion); } }); From 3e15ae7274290fe9cd31a07c25b8aea74301d616 Mon Sep 17 00:00:00 2001 From: allison-truhlar Date: Mon, 2 Feb 2026 12:53:29 -0500 Subject: [PATCH 012/104] fix: update locators to match new defaults --- .../ui-tests/tests/load-zarr-files.spec.ts | 24 +++++++++---------- 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/frontend/ui-tests/tests/load-zarr-files.spec.ts b/frontend/ui-tests/tests/load-zarr-files.spec.ts index deaeb46ed..a3ca9bd99 100644 --- a/frontend/ui-tests/tests/load-zarr-files.spec.ts +++ b/frontend/ui-tests/tests/load-zarr-files.spec.ts @@ -33,9 +33,9 @@ test.describe('Zarr File Type Representation', () => { await expect(page.getByText('zarr.json')).toBeVisible({ timeout: 10000 }); await expect( - page.getByRole('link', { name: 'Neuroglancer logo' }) + page.getByRole('img', { name: /neuroglancer logo/i }) ).toBeVisible({ timeout: 10000 }); - await expect(page.getByRole('link', { name: 'Vol-E logo' })).toHaveCount(0); + await expect(page.getByRole('img', { name: /vole logo/i })).toHaveCount(0); }); test('Zarr V3 OME-Zarr should show all viewers except avivator', async ({ @@ -52,13 +52,13 @@ test.describe('Zarr File Type Representation', () => { await expect(page.getByText('zarr.json')).toBeVisible({ timeout: 10000 }); await expect( - page.getByRole('link', { name: 'Neuroglancer logo' }) + page.getByRole('img', { name: /neuroglancer logo/i }) ).toBeVisible({ timeout: 10000 }); - await expect(page.getByRole('link', { name: 'Vol-E logo' })).toBeVisible(); + await expect(page.getByRole('img', { name: /vole logo/i })).toBeVisible(); await expect( - page.getByRole('link', { name: 'OME-Zarr Validator logo' }) + page.getByRole('img', { name: /validator logo/i }) ).toBeVisible(); - await expect(page.getByRole('link', { name: 'Avivator logo' })).toHaveCount( + await expect(page.getByRole('img', { name: /avivator logo/i })).toHaveCount( 0 ); }); @@ -77,9 +77,9 @@ test.describe('Zarr File Type Representation', () => { await expect(page.getByText('.zarray')).toBeVisible({ timeout: 10000 }); await expect( - page.getByRole('link', { name: 'Neuroglancer logo' }) + page.getByRole('img', { name: /neuroglancer logo/i }) ).toBeVisible({ timeout: 10000 }); - await expect(page.getByRole('link', { name: 'Vol-E logo' })).toHaveCount(0); + await expect(page.getByRole('img', { name: /vole logo/i })).toHaveCount(0); }); test('Zarr V2 OME-Zarr should display all viewers including avivator', async ({ @@ -96,14 +96,14 @@ test.describe('Zarr File Type Representation', () => { await expect(page.getByText('.zattrs')).toBeVisible({ timeout: 10000 }); await expect( - page.getByRole('link', { name: 'Neuroglancer logo' }) + page.getByRole('img', { name: /neuroglancer logo/i }) ).toBeVisible({ timeout: 10000 }); - await expect(page.getByRole('link', { name: 'Vol-E logo' })).toBeVisible(); + await expect(page.getByRole('img', { name: /vole logo/i })).toBeVisible(); await expect( - page.getByRole('link', { name: 'OME-Zarr Validator logo' }) + page.getByRole('img', { name: /validator logo/i }) ).toBeVisible(); await expect( - page.getByRole('link', { name: 'Avivator logo' }) + page.getByRole('img', { name: /avivator logo/i }) ).toBeVisible(); }); From 4292cdfa509d04eb956665de05648f43a471d51f Mon Sep 17 00:00:00 2001 From: allison-truhlar Date: Mon, 2 Feb 2026 12:53:59 -0500 Subject: [PATCH 013/104] efactor: use committed config file instead of template --- docs/Development.md | 11 +++----- docs/ViewersConfiguration.md | 18 ++++--------- frontend/.gitignore | 1 - .../src/config/viewers.config.yaml | 25 +++++++------------ 4 files changed, 17 insertions(+), 38 deletions(-) delete mode 100644 frontend/.gitignore rename docs/viewers.config.yaml.template => frontend/src/config/viewers.config.yaml (65%) diff --git a/docs/Development.md b/docs/Development.md index 0d82c3f3b..742948e30 100644 --- a/docs/Development.md +++ b/docs/Development.md @@ -72,16 +72,11 @@ Fileglancer supports dynamic configuration of OME-Zarr viewers through `viewers. **Quick Setup:** -1. Copy the template to the config directory: - ```bash - cp docs/viewers.config.yaml.template frontend/src/config/viewers.config.yaml - ``` +1. Edit the configuration file at `frontend/src/config/viewers.config.yaml` to enable/disable viewers or customize URLs -2. Edit `frontend/src/config/viewers.config.yaml` to enable/disable viewers or customize URLs +2. Rebuild the application: `pixi run node-build` or use watch mode in development: `pixi run dev-watch` -3. Rebuild the application: `pixi run node-build` or use watch mode in development: `pixi run dev-watch` - -**Note:** The configuration file is bundled at build time, so changes require rebuilding the application. +**Note:** The configuration file is bundled at build time, so changes require rebuilding the application. The default configuration includes Neuroglancer, Avivator, OME-Zarr Validator, and Vol-E viewers. For detailed configuration options, examples, and documentation on adding custom viewers, see [ViewersConfiguration.md](ViewersConfiguration.md). diff --git a/docs/ViewersConfiguration.md b/docs/ViewersConfiguration.md index 6fd7f0cb7..324b29b4e 100644 --- a/docs/ViewersConfiguration.md +++ b/docs/ViewersConfiguration.md @@ -12,23 +12,17 @@ The viewer system uses: ## Quick Start -1. Copy the template to the config directory: +1. Edit the configuration file at `frontend/src/config/viewers.config.yaml` to enable/disable viewers or customize URLs -```bash -cp docs/viewers.config.yaml.template frontend/src/config/viewers.config.yaml -``` - -2. Edit `frontend/src/config/viewers.config.yaml` to enable/disable viewers or customize URLs - -3. Build the application - configuration is bundled at build time +2. Build the application - configuration is bundled at build time ## Configuration File Location -Place `viewers.config.yaml` in `frontend/src/config/` directory. +The configuration file is located at `frontend/src/config/viewers.config.yaml`. **Important:** This file is bundled at build time. Changes require rebuilding the application. -If no configuration file exists, Fileglancer defaults to Neuroglancer only. +The default configuration includes Neuroglancer, Avivator, OME-Zarr Validator, and Vol-E viewers. You can modify this file to add, remove, or customize viewers for your deployment. ## Viewer Types @@ -139,12 +133,10 @@ Simple version matching: When developing with custom configurations: -1. Create/edit `frontend/src/config/viewers.config.yaml` +1. Edit `frontend/src/config/viewers.config.yaml` 2. Rebuild frontend: `pixi run node-build` or use watch mode: `pixi run dev-watch` 3. Check console for initialization messages -**Note:** The config file is gitignored to allow per-deployment customization without committing changes. - ## Copy URL Tool The "Copy data URL" tool is always available when a data URL exists, regardless of viewer configuration. diff --git a/frontend/.gitignore b/frontend/.gitignore deleted file mode 100644 index c979e0f11..000000000 --- a/frontend/.gitignore +++ /dev/null @@ -1 +0,0 @@ -src/config/viewers.config.yaml diff --git a/docs/viewers.config.yaml.template b/frontend/src/config/viewers.config.yaml similarity index 65% rename from docs/viewers.config.yaml.template rename to frontend/src/config/viewers.config.yaml index 3883399ee..6beb3e7b5 100644 --- a/docs/viewers.config.yaml.template +++ b/frontend/src/config/viewers.config.yaml @@ -4,11 +4,6 @@ # The @bioimagetools/capability-manifest library is used to determine compatibility # for viewers that have a capability manifest. # -# To use this file: -# 1. Copy this template to the project root: cp docs/viewers.config.yaml.template viewers.config.yaml -# 2. Uncommented viewers will be shown in your deployment -# 3. Check the values provided for each viewer - see guidelines below. -# # For viewers with capability manifests, you must provide: # - name: must match name value in capability manifest # Optionally: @@ -31,21 +26,19 @@ viewers: - name: avivator # Optional: Override the viewer URL from the capability manifest # In this example, override to use Janelia's custom deployment - url: "https://janeliascicomp.github.io/viv/" + url: 'https://janeliascicomp.github.io/viv/' - # # OME-Zarr viewers without capability manifests - # # Example: + # OME-Zarr viewers without capability manifests # OME-Zarr Validator # Logo will automatically resolve to @/assets/validator.png - name: validator - url: "https://ome.github.io/ome-ngff-validator/?source={dataLink}" + url: 'https://ome.github.io/ome-ngff-validator/?source={dataLink}' ome_zarr_versions: [0.4, 0.5] - label: "View in OME-Zarr Validator" + label: 'View in OME-Zarr Validator' - # # Example: - # # Vol-E - Allen Cell Explorer 3D viewer - # # Logo will automatically resolve to @/assets/vole.png + # Vol-E - Allen Cell Explorer 3D viewer + # Logo will automatically resolve to @/assets/vole.png - name: vole - url: "https://volumeviewer.allencell.org/viewer?url={dataLink}" - ome_zarr_versions: [0.4] - label: "View in Vol-E" + url: 'https://volumeviewer.allencell.org/viewer?url={dataLink}' + ome_zarr_versions: [0.4, 0.5] + label: 'View in Vol-E' From d545e2810100848f37ceabd83be0919f9d1029ce Mon Sep 17 00:00:00 2001 From: allison-truhlar Date: Mon, 2 Feb 2026 12:54:37 -0500 Subject: [PATCH 014/104] docs: add viewers configuration to CLAUDE.md --- CLAUDE.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index fcd4de6a9..8f1f444db 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -169,6 +169,20 @@ Key settings: - `db_url`: Database connection string - SSL certificates for HTTPS mode +## Viewers Configuration + +Fileglancer supports dynamic viewer configuration through `viewers.config.yaml`. + +- **Configuration file**: `frontend/src/config/viewers.config.yaml` +- **Documentation**: See `docs/ViewersConfiguration.md` + +To customize viewers: + +1. Edit `frontend/src/config/viewers.config.yaml` +2. Rebuild application: `pixi run node-build` + +The default configuration includes Neuroglancer, Avivator, OME-Zarr Validator, and Vol-E viewers. The config file is bundled at build time. + ## Pixi Environments - `default`: Standard development From d5c24e9d1b76f7aea3dc7900f0bd062c9d313214 Mon Sep 17 00:00:00 2001 From: allison-truhlar Date: Mon, 2 Feb 2026 12:54:57 -0500 Subject: [PATCH 015/104] refactor: remove unused keys from N5OpenWithToolUrls type --- frontend/src/hooks/useN5Metadata.ts | 5 +---- frontend/src/queries/n5Queries.ts | 3 --- 2 files changed, 1 insertion(+), 7 deletions(-) diff --git a/frontend/src/hooks/useN5Metadata.ts b/frontend/src/hooks/useN5Metadata.ts index f432c1ce0..1187d0a96 100644 --- a/frontend/src/hooks/useN5Metadata.ts +++ b/frontend/src/hooks/useN5Metadata.ts @@ -73,10 +73,7 @@ export default function useN5Metadata() { const toolUrls: N5OpenWithToolUrls = { copy: url || '', - neuroglancer: '', - validator: null, - vole: null, - avivator: null + neuroglancer: '' }; if (url) { diff --git a/frontend/src/queries/n5Queries.ts b/frontend/src/queries/n5Queries.ts index 3f15ba6a2..699e9ab4e 100644 --- a/frontend/src/queries/n5Queries.ts +++ b/frontend/src/queries/n5Queries.ts @@ -49,9 +49,6 @@ export type N5Metadata = { export type N5OpenWithToolUrls = { copy: string; neuroglancer: string; - validator: null; - vole: null; - avivator: null; }; type N5MetadataQueryParams = { From e6e1cd451c5482aa442ef25410f8b8ae10645533 Mon Sep 17 00:00:00 2001 From: allison-truhlar Date: Mon, 2 Feb 2026 12:58:11 -0500 Subject: [PATCH 016/104] perf: memoize getCompatibleViewers function Add useCallback to memoize the getCompatibleViewers function in ViewersContext to prevent unnecessary recalculations and improve performance when filtering compatible viewers based on metadata. --- frontend/src/contexts/ViewersContext.tsx | 57 +++++++++++++----------- 1 file changed, 31 insertions(+), 26 deletions(-) diff --git a/frontend/src/contexts/ViewersContext.tsx b/frontend/src/contexts/ViewersContext.tsx index 51a43176e..aba347453 100644 --- a/frontend/src/contexts/ViewersContext.tsx +++ b/frontend/src/contexts/ViewersContext.tsx @@ -3,6 +3,7 @@ import { useContext, useState, useEffect, + useCallback, type ReactNode } from 'react'; import { @@ -195,35 +196,39 @@ export function ViewersProvider({ children }: { children: ReactNode }) { initialize(); }, []); - const getCompatibleViewers = (metadata: OmeZarrMetadata): ValidViewer[] => { - if (!isInitialized || !metadata) { - return []; - } + const getCompatibleViewers = useCallback( + (metadata: OmeZarrMetadata): ValidViewer[] => { + if (!isInitialized || !metadata) { + return []; + } - return validViewers.filter(viewer => { - if (viewer.manifest) { - const compatibleNames = getCompatibleViewersFromManifest(metadata); - return compatibleNames.includes(viewer.manifest.viewer.name); - } else { - // Manual version check for viewers without manifests - // Custom viewers require both correct ome-zarr version AND OME metadata (multiscales) - const zarrVersion = metadata.version - ? parseFloat(metadata.version) - : null; - if (zarrVersion === null || !viewer.supportedVersions) { - return false; - } + return validViewers.filter(viewer => { + if (viewer.manifest) { + const compatibleNames = getCompatibleViewersFromManifest(metadata); + return compatibleNames.includes(viewer.manifest.viewer.name); + } else { + // Manual version check for viewers without manifests + // Custom viewers require both correct ome-zarr version AND OME metadata (multiscales) + const zarrVersion = metadata.version + ? parseFloat(metadata.version) + : null; + if (zarrVersion === null || !viewer.supportedVersions) { + return false; + } - // Check if dataset has OME metadata (multiscales array) - const hasOmeMetadata = metadata.multiscales && metadata.multiscales.length > 0; - if (!hasOmeMetadata) { - return false; - } + // Check if dataset has OME metadata (multiscales array) + const hasOmeMetadata = + metadata.multiscales && metadata.multiscales.length > 0; + if (!hasOmeMetadata) { + return false; + } - return viewer.supportedVersions.includes(zarrVersion); - } - }); - }; + return viewer.supportedVersions.includes(zarrVersion); + } + }); + }, + [validViewers, isInitialized] + ); return ( Date: Mon, 2 Feb 2026 13:01:05 -0500 Subject: [PATCH 017/104] feat: improve error handling in viewers initialization Add more detailed error logging and ensure graceful degradation when: - Capability manifests fail to load - Viewer configuration parsing fails - No valid viewers are configured The application will continue with an empty viewer list and clear console messages to help users troubleshoot configuration issues. --- frontend/src/contexts/ViewersContext.tsx | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/frontend/src/contexts/ViewersContext.tsx b/frontend/src/contexts/ViewersContext.tsx index aba347453..7e5e5e7fb 100644 --- a/frontend/src/contexts/ViewersContext.tsx +++ b/frontend/src/contexts/ViewersContext.tsx @@ -110,6 +110,9 @@ export function ViewersProvider({ children }: { children: ReactNode }) { ); } catch (manifestError) { log.warn('Failed to load capability manifests:', manifestError); + log.warn( + 'Continuing without capability manifests. Only custom viewers with explicit configuration will be available.' + ); } const viewersWithManifests = loadedManifests.map(m => m.viewer.name); @@ -188,7 +191,11 @@ export function ViewersProvider({ children }: { children: ReactNode }) { const errorMessage = err instanceof Error ? err.message : 'Unknown error'; log.error('Failed to initialize viewers:', errorMessage); + log.error( + 'Application will continue with no viewers available. Check viewers.config.yaml for errors.' + ); setError(errorMessage); + setValidViewers([]); // Ensure empty viewer list on error setIsInitialized(true); // Still mark as initialized to prevent hanging } } From 58d163d7ab4dcf8560f6659057c3a6592c09fde5 Mon Sep 17 00:00:00 2001 From: allison-truhlar Date: Mon, 2 Feb 2026 15:17:00 -0500 Subject: [PATCH 018/104] chore: fix eslint warnings --- frontend/src/contexts/ViewersContext.tsx | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/frontend/src/contexts/ViewersContext.tsx b/frontend/src/contexts/ViewersContext.tsx index 7e5e5e7fb..a2abc4e1a 100644 --- a/frontend/src/contexts/ViewersContext.tsx +++ b/frontend/src/contexts/ViewersContext.tsx @@ -65,6 +65,7 @@ async function loadViewersConfig( log.info( 'Using custom viewers configuration from src/config/viewers.config.yaml' ); + // eslint-disable-next-line @typescript-eslint/no-unused-vars } catch (error) { log.info( 'No custom viewers.config.yaml found, using default configuration (neuroglancer only)' @@ -89,11 +90,14 @@ function normalizeViewerName(name: string): string { return name.toLowerCase().replace(/[^a-z0-9]/g, ''); } -export function ViewersProvider({ children }: { children: ReactNode }) { +export function ViewersProvider({ + children +}: { + readonly children: ReactNode; +}) { const [validViewers, setValidViewers] = useState([]); const [isInitialized, setIsInitialized] = useState(false); const [error, setError] = useState(null); - const [manifests, setManifests] = useState([]); useEffect(() => { async function initialize() { @@ -104,7 +108,6 @@ export function ViewersProvider({ children }: { children: ReactNode }) { let loadedManifests: ViewerManifest[] = []; try { loadedManifests = await initializeViewerManifests(); - setManifests(loadedManifests); log.info( `Loaded ${loadedManifests.length} viewer capability manifests` ); From 5ae87aeaa6331c3d8977eed1d87704101fbd65aa Mon Sep 17 00:00:00 2001 From: allison-truhlar Date: Mon, 2 Feb 2026 16:03:51 -0500 Subject: [PATCH 019/104] fix: add validation for ome-zarr verison numbers --- frontend/src/config/viewersConfig.ts | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/frontend/src/config/viewersConfig.ts b/frontend/src/config/viewersConfig.ts index 6237c913b..4507e9c67 100644 --- a/frontend/src/config/viewersConfig.ts +++ b/frontend/src/config/viewersConfig.ts @@ -18,6 +18,11 @@ export interface ViewersConfigYaml { viewers: ViewerConfigEntry[]; } +/** + * Valid OME-Zarr versions supported by the application + */ +const VALID_OME_ZARR_VERSIONS = [0.4, 0.5]; + /** * Parse and validate viewers configuration YAML * @param yamlContent - The YAML content to parse @@ -104,6 +109,20 @@ export function parseViewersConfig( `Viewer "${v.name}": "ome_zarr_versions" must be an array` ); } + + // Validate ome_zarr_versions values if present + if ( + v.ome_zarr_versions !== undefined && + Array.isArray(v.ome_zarr_versions) + ) { + for (const version of v.ome_zarr_versions) { + if (!VALID_OME_ZARR_VERSIONS.includes(version)) { + throw new Error( + `Viewer "${v.name}": invalid ome_zarr_version "${version}". Valid versions are: ${VALID_OME_ZARR_VERSIONS.join(', ')}` + ); + } + } + } } // Type assertion is safe here because we've performed comprehensive runtime validation above. From 4b6db2e2f76e33bcbcae742a2ffbdf7acc2322a3 Mon Sep 17 00:00:00 2001 From: allison-truhlar Date: Mon, 2 Feb 2026 16:04:10 -0500 Subject: [PATCH 020/104] tests: unit tests for the viewer config parsing func --- .../__tests__/unitTests/viewersConfig.test.ts | 515 ++++++++++++++++++ 1 file changed, 515 insertions(+) create mode 100644 frontend/src/__tests__/unitTests/viewersConfig.test.ts diff --git a/frontend/src/__tests__/unitTests/viewersConfig.test.ts b/frontend/src/__tests__/unitTests/viewersConfig.test.ts new file mode 100644 index 000000000..815c31406 --- /dev/null +++ b/frontend/src/__tests__/unitTests/viewersConfig.test.ts @@ -0,0 +1,515 @@ +import { describe, it, expect } from 'vitest'; +import { parseViewersConfig } from '@/config/viewersConfig'; + +describe('parseViewersConfig', () => { + describe('Valid configurations', () => { + it('should parse valid config with viewers that have manifests', () => { + const yaml = ` +viewers: + - name: neuroglancer + - name: avivator +`; + const result = parseViewersConfig(yaml, ['neuroglancer', 'avivator']); + + expect(result.viewers).toHaveLength(2); + expect(result.viewers[0].name).toBe('neuroglancer'); + expect(result.viewers[1].name).toBe('avivator'); + }); + + it('should parse config with custom viewer with all required fields', () => { + const yaml = ` +viewers: + - name: custom-viewer + url: https://example.com/{dataLink} + ome_zarr_versions: [0.4, 0.5] +`; + const result = parseViewersConfig(yaml, []); + + expect(result.viewers).toHaveLength(1); + expect(result.viewers[0].name).toBe('custom-viewer'); + expect(result.viewers[0].url).toBe('https://example.com/{dataLink}'); + expect(result.viewers[0].ome_zarr_versions).toEqual([0.4, 0.5]); + }); + + it('should parse config with optional fields', () => { + const yaml = ` +viewers: + - name: custom-viewer + url: https://example.com/{dataLink} + ome_zarr_versions: [0.4] + label: Custom Viewer Label + logo: custom-logo.png +`; + const result = parseViewersConfig(yaml, []); + + expect(result.viewers[0].label).toBe('Custom Viewer Label'); + expect(result.viewers[0].logo).toBe('custom-logo.png'); + }); + + it('should allow viewer with manifest to override url', () => { + const yaml = ` +viewers: + - name: neuroglancer + url: https://custom-neuroglancer.com/{dataLink} +`; + const result = parseViewersConfig(yaml, ['neuroglancer']); + + expect(result.viewers[0].url).toBe( + 'https://custom-neuroglancer.com/{dataLink}' + ); + }); + + it('should parse mixed config with manifest and non-manifest viewers', () => { + const yaml = ` +viewers: + - name: neuroglancer + - name: custom-viewer + url: https://example.com/{dataLink} + ome_zarr_versions: [0.4] + - name: avivator +`; + const result = parseViewersConfig(yaml, ['neuroglancer', 'avivator']); + + expect(result.viewers).toHaveLength(3); + expect(result.viewers[0].name).toBe('neuroglancer'); + expect(result.viewers[1].name).toBe('custom-viewer'); + expect(result.viewers[2].name).toBe('avivator'); + }); + }); + + describe('Invalid YAML syntax', () => { + it('should throw error for malformed YAML', () => { + const invalidYaml = 'viewers:\n - name: test\n invalid: [[['; + + expect(() => parseViewersConfig(invalidYaml, [])).toThrow( + /Failed to parse viewers configuration YAML/ + ); + }); + + it('should throw error for invalid YAML structure', () => { + const invalidYaml = 'this is not valid yaml [[{]}'; + + // js-yaml parses this as a string, which then fails the object check + expect(() => parseViewersConfig(invalidYaml, [])).toThrow( + /Configuration must be an object/ + ); + }); + + it('should throw error for non-object YAML', () => { + const invalidYaml = 'just a string'; + + expect(() => parseViewersConfig(invalidYaml, [])).toThrow( + /Configuration must be an object/ + ); + }); + + it('should throw error for empty YAML', () => { + const invalidYaml = ''; + + expect(() => parseViewersConfig(invalidYaml, [])).toThrow( + /Configuration must be an object/ + ); + }); + }); + + describe('Missing required fields', () => { + it('should throw error when viewers array is missing', () => { + const yaml = ` +name: some-config +other_field: value +`; + + expect(() => parseViewersConfig(yaml, [])).toThrow( + /Configuration must have a "viewers" array/ + ); + }); + + it('should throw error when viewers is not an array', () => { + const yaml = ` +viewers: not-an-array +`; + + expect(() => parseViewersConfig(yaml, [])).toThrow( + /Configuration must have a "viewers" array/ + ); + }); + + it('should throw error when viewer is not an object', () => { + const yaml = ` +viewers: + - just-a-string +`; + + expect(() => parseViewersConfig(yaml, [])).toThrow( + /Each viewer must be an object/ + ); + }); + + it('should throw error when viewer lacks name field', () => { + const yaml = ` +viewers: + - url: https://example.com + ome_zarr_versions: [0.4] +`; + + expect(() => parseViewersConfig(yaml, [])).toThrow( + /Each viewer must have a "name" field \(string\)/ + ); + }); + + it('should throw error when viewer name is not a string', () => { + const yaml = ` +viewers: + - name: 123 + url: https://example.com + ome_zarr_versions: [0.4] +`; + + expect(() => parseViewersConfig(yaml, [])).toThrow( + /Each viewer must have a "name" field \(string\)/ + ); + }); + + it('should throw error when custom viewer (no manifest) lacks url', () => { + const yaml = ` +viewers: + - name: custom-viewer + ome_zarr_versions: [0.4] +`; + + expect(() => parseViewersConfig(yaml, [])).toThrow( + /Viewer "custom-viewer" does not have a capability manifest and must specify "url"/ + ); + }); + + it('should throw error when custom viewer (no manifest) lacks ome_zarr_versions', () => { + const yaml = ` +viewers: + - name: custom-viewer + url: https://example.com/{dataLink} +`; + + expect(() => parseViewersConfig(yaml, [])).toThrow( + /Viewer "custom-viewer" does not have a capability manifest and must specify "ome_zarr_versions"/ + ); + }); + + it('should throw error when custom viewer has empty ome_zarr_versions array', () => { + const yaml = ` +viewers: + - name: custom-viewer + url: https://example.com/{dataLink} + ome_zarr_versions: [] +`; + + expect(() => parseViewersConfig(yaml, [])).toThrow( + /Viewer "custom-viewer" does not have a capability manifest and must specify "ome_zarr_versions"/ + ); + }); + }); + + describe('Invalid field types', () => { + it('should throw error when url is not a string (for custom viewer)', () => { + const yaml = ` +viewers: + - name: custom-viewer + url: 123 + ome_zarr_versions: [0.4] +`; + + // The required field check happens first, so if url is wrong type, + // it's caught by the "must specify url" check + expect(() => parseViewersConfig(yaml, [])).toThrow( + /Viewer "custom-viewer" does not have a capability manifest and must specify "url"/ + ); + }); + + it('should throw error when url override is not a string (for manifest viewer)', () => { + const yaml = ` +viewers: + - name: neuroglancer + url: 123 +`; + + expect(() => parseViewersConfig(yaml, ['neuroglancer'])).toThrow( + /Viewer "neuroglancer": "url" must be a string/ + ); + }); + + it('should throw error when label is not a string', () => { + const yaml = ` +viewers: + - name: custom-viewer + url: https://example.com + ome_zarr_versions: [0.4] + label: 123 +`; + + expect(() => parseViewersConfig(yaml, [])).toThrow( + /Viewer "custom-viewer": "label" must be a string/ + ); + }); + + it('should throw error when logo is not a string', () => { + const yaml = ` +viewers: + - name: custom-viewer + url: https://example.com + ome_zarr_versions: [0.4] + logo: 123 +`; + + expect(() => parseViewersConfig(yaml, [])).toThrow( + /Viewer "custom-viewer": "logo" must be a string/ + ); + }); + + it('should throw error when ome_zarr_versions is not an array (for custom viewer)', () => { + const yaml = ` +viewers: + - name: custom-viewer + url: https://example.com + ome_zarr_versions: "not-an-array" +`; + + // The required field check happens first and checks if it's an array + expect(() => parseViewersConfig(yaml, [])).toThrow( + /Viewer "custom-viewer" does not have a capability manifest and must specify "ome_zarr_versions"/ + ); + }); + + it('should throw error when ome_zarr_versions override is not an array (for manifest viewer)', () => { + const yaml = ` +viewers: + - name: neuroglancer + ome_zarr_versions: "not-an-array" +`; + + expect(() => parseViewersConfig(yaml, ['neuroglancer'])).toThrow( + /Viewer "neuroglancer": "ome_zarr_versions" must be an array/ + ); + }); + }); + + describe('OME-Zarr version validation', () => { + it('should accept valid ome_zarr_versions (0.4 and 0.5)', () => { + const yaml = ` +viewers: + - name: custom-viewer + url: https://example.com + ome_zarr_versions: [0.4, 0.5] +`; + + const result = parseViewersConfig(yaml, []); + + expect(result.viewers[0].ome_zarr_versions).toEqual([0.4, 0.5]); + }); + + it('should throw error for invalid ome_zarr_version (0.3)', () => { + const yaml = ` +viewers: + - name: custom-viewer + url: https://example.com + ome_zarr_versions: [0.3] +`; + + expect(() => parseViewersConfig(yaml, [])).toThrow( + /invalid ome_zarr_version "0.3". Valid versions are: 0.4, 0.5/ + ); + }); + + it('should throw error for invalid ome_zarr_version (1.0)', () => { + const yaml = ` +viewers: + - name: custom-viewer + url: https://example.com + ome_zarr_versions: [1.0] +`; + + expect(() => parseViewersConfig(yaml, [])).toThrow( + /invalid ome_zarr_version "1". Valid versions are: 0.4, 0.5/ + ); + }); + + it('should throw error when mixing valid and invalid versions', () => { + const yaml = ` +viewers: + - name: custom-viewer + url: https://example.com + ome_zarr_versions: [0.3, 0.4, 0.5] +`; + + expect(() => parseViewersConfig(yaml, [])).toThrow( + /invalid ome_zarr_version "0.3". Valid versions are: 0.4, 0.5/ + ); + }); + + it('should throw error for invalid version in manifest viewer override', () => { + const yaml = ` +viewers: + - name: neuroglancer + ome_zarr_versions: [0.3] +`; + + expect(() => parseViewersConfig(yaml, ['neuroglancer'])).toThrow( + /invalid ome_zarr_version "0.3". Valid versions are: 0.4, 0.5/ + ); + }); + + it('should accept only 0.4', () => { + const yaml = ` +viewers: + - name: custom-viewer + url: https://example.com + ome_zarr_versions: [0.4] +`; + + const result = parseViewersConfig(yaml, []); + + expect(result.viewers[0].ome_zarr_versions).toEqual([0.4]); + }); + + it('should accept only 0.5', () => { + const yaml = ` +viewers: + - name: custom-viewer + url: https://example.com + ome_zarr_versions: [0.5] +`; + + const result = parseViewersConfig(yaml, []); + + expect(result.viewers[0].ome_zarr_versions).toEqual([0.5]); + }); + }); + + describe('Case sensitivity and normalization', () => { + it('should handle case-insensitive manifest matching', () => { + const yaml = ` +viewers: + - name: Neuroglancer + - name: AVIVATOR +`; + + // Manifest names are lowercase + const result = parseViewersConfig(yaml, ['neuroglancer', 'avivator']); + + expect(result.viewers).toHaveLength(2); + expect(result.viewers[0].name).toBe('Neuroglancer'); + expect(result.viewers[1].name).toBe('AVIVATOR'); + }); + + it('should match manifests case-insensitively for mixed case', () => { + const yaml = ` +viewers: + - name: NeuroGlancer +`; + + // Should recognize this has a manifest (neuroglancer) + const result = parseViewersConfig(yaml, ['neuroglancer']); + + expect(result.viewers).toHaveLength(1); + expect(result.viewers[0].name).toBe('NeuroGlancer'); + // Should not require url or ome_zarr_versions since it has a manifest + }); + }); + + describe('Edge cases', () => { + it('should handle empty viewers array', () => { + const yaml = ` +viewers: [] +`; + + const result = parseViewersConfig(yaml, []); + + expect(result.viewers).toHaveLength(0); + }); + + it('should handle viewer with only name (has manifest)', () => { + const yaml = ` +viewers: + - name: neuroglancer +`; + + const result = parseViewersConfig(yaml, ['neuroglancer']); + + expect(result.viewers).toHaveLength(1); + expect(result.viewers[0]).toEqual({ name: 'neuroglancer' }); + }); + + it('should preserve all valid fields in parsed output', () => { + const yaml = ` +viewers: + - name: custom + url: https://example.com + label: Custom Label + logo: custom.png + ome_zarr_versions: [0.4, 0.5] +`; + + const result = parseViewersConfig(yaml, []); + + expect(result.viewers[0]).toEqual({ + name: 'custom', + url: 'https://example.com', + label: 'Custom Label', + logo: 'custom.png', + ome_zarr_versions: [0.4, 0.5] + }); + }); + + it('should handle multiple valid ome_zarr_versions', () => { + const yaml = ` +viewers: + - name: custom + url: https://example.com + ome_zarr_versions: [0.4, 0.5] +`; + + const result = parseViewersConfig(yaml, []); + + expect(result.viewers[0].ome_zarr_versions).toEqual([0.4, 0.5]); + }); + + it('should handle single ome_zarr_version in array', () => { + const yaml = ` +viewers: + - name: custom + url: https://example.com + ome_zarr_versions: [0.4] +`; + + const result = parseViewersConfig(yaml, []); + + expect(result.viewers[0].ome_zarr_versions).toEqual([0.4]); + }); + }); + + describe('Default parameter behavior', () => { + it('should use empty array as default for viewersWithManifests', () => { + const yaml = ` +viewers: + - name: custom + url: https://example.com + ome_zarr_versions: [0.4] +`; + + // Not passing second parameter + const result = parseViewersConfig(yaml); + + expect(result.viewers).toHaveLength(1); + expect(result.viewers[0].name).toBe('custom'); + }); + + it('should treat viewer as non-manifest when viewersWithManifests is empty', () => { + const yaml = ` +viewers: + - name: neuroglancer +`; + + // Even though neuroglancer typically has a manifest, + // if not in the list, it should require url and versions + expect(() => parseViewersConfig(yaml, [])).toThrow(/must specify "url"/); + }); + }); +}); From fef30cdb18487339461c0ee8b49899d8f27b7500 Mon Sep 17 00:00:00 2001 From: allison-truhlar Date: Mon, 2 Feb 2026 16:04:32 -0500 Subject: [PATCH 021/104] tests: component tests for DataToolLinks --- .../componentTests/DataToolLinks.test.tsx | 355 ++++++++++++++++++ 1 file changed, 355 insertions(+) create mode 100644 frontend/src/__tests__/componentTests/DataToolLinks.test.tsx diff --git a/frontend/src/__tests__/componentTests/DataToolLinks.test.tsx b/frontend/src/__tests__/componentTests/DataToolLinks.test.tsx new file mode 100644 index 000000000..90cd8257a --- /dev/null +++ b/frontend/src/__tests__/componentTests/DataToolLinks.test.tsx @@ -0,0 +1,355 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { waitFor } from '@testing-library/react'; +import { render, screen } from '@/__tests__/test-utils'; +import DataToolLinks from '@/components/ui/BrowsePage/DataToolLinks'; +import type { OpenWithToolUrls, PendingToolKey } from '@/hooks/useZarrMetadata'; +import { ViewersProvider } from '@/contexts/ViewersContext'; + +// Mock logger to capture console warnings +const mockLogger = vi.hoisted(() => ({ + info: vi.fn(), + warn: vi.fn(), + error: vi.fn() +})); + +vi.mock('@/logger', () => ({ + default: mockLogger +})); + +// Mock capability manifest to avoid network requests in tests +vi.mock('@bioimagetools/capability-manifest', () => ({ + initializeViewerManifests: vi.fn(async () => [ + { + viewer: { + name: 'neuroglancer', + template_url: 'https://neuroglancer.com/#{dataLink}' + } + }, + { + viewer: { + name: 'avivator', + template_url: 'https://avivator.com/?url={dataLink}' + } + } + ]), + getCompatibleViewers: vi.fn(() => ['neuroglancer', 'avivator']) +})); + +const mockOpenWithToolUrls: OpenWithToolUrls = { + copy: 'http://localhost:3000/test/copy/url', + validator: 'http://localhost:3000/test/validator/url', + neuroglancer: 'http://localhost:3000/test/neuroglancer/url', + vole: 'http://localhost:3000/test/vole/url', + avivator: 'http://localhost:3000/test/avivator/url' +}; + +// Helper component to wrap DataToolLinks with ViewersProvider +function TestDataToolLinksComponent({ + urls = mockOpenWithToolUrls, + onToolClick = vi.fn() +}: { + urls?: OpenWithToolUrls | null; + onToolClick?: (toolKey: PendingToolKey) => Promise; +}) { + return ( + + + + ); +} + +// Wrapper function for rendering with proper route context +function renderDataToolLinks( + urls?: OpenWithToolUrls | null, + onToolClick?: (toolKey: PendingToolKey) => Promise +) { + return render( + , + { initialEntries: ['/browse/test_fsp/test_file'] } + ); +} + +describe('DataToolLinks - Error Scenarios', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('Invalid YAML syntax', () => { + it('should log error when YAML parsing fails in ViewersContext', async () => { + // This test verifies that the ViewersContext logs errors appropriately + // The actual YAML parsing error is tested in the ViewersContext initialization + + // Import the parseViewersConfig function to test it directly + const { parseViewersConfig } = await import('@/config/viewersConfig'); + + const invalidYaml = 'viewers:\n - name: test\n invalid: [[['; + + expect(() => parseViewersConfig(invalidYaml, [])).toThrow( + /Failed to parse viewers configuration YAML/ + ); + }); + + it('should still render when ViewersContext fails to initialize', async () => { + // When ViewersContext fails to initialize, it sets error state + // and logs to console. The component should still render but with empty viewers. + renderDataToolLinks(); + + await waitFor( + () => { + // The component should still be initialized (to prevent hanging) + // but viewers may be empty if there was an error + expect(screen.getByText('Test Tools')).toBeInTheDocument(); + }, + { timeout: 3000 } + ); + }); + }); + + describe('Missing required fields', () => { + it('should throw error when custom viewer lacks required url field', async () => { + const { parseViewersConfig } = await import('@/config/viewersConfig'); + + const configMissingUrl = ` +viewers: + - name: custom-viewer + # Missing url for viewer without manifest +`; + + expect(() => parseViewersConfig(configMissingUrl, [])).toThrow( + /does not have a capability manifest and must specify "url"/ + ); + }); + + it('should throw error when custom viewer lacks ome_zarr_versions', async () => { + const { parseViewersConfig } = await import('@/config/viewersConfig'); + + const configMissingVersions = ` +viewers: + - name: custom-viewer + url: https://example.com + # Missing ome_zarr_versions for viewer without manifest +`; + + expect(() => parseViewersConfig(configMissingVersions, [])).toThrow( + /does not have a capability manifest and must specify "ome_zarr_versions"/ + ); + }); + }); +}); + +describe('DataToolLinks - Edge Cases', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('Fallback logo handling', () => { + it('should handle viewers with custom logos', async () => { + // Test that getViewerLogo returns appropriate path + const { getViewerLogo } = await import('@/config/viewerLogos'); + + // Test with known viewer + const neuroglancerLogo = getViewerLogo('neuroglancer'); + expect(neuroglancerLogo).toBeTruthy(); + + // Test with custom logo path + const customLogo = getViewerLogo('custom-viewer', 'custom-logo.png'); + expect(customLogo).toBeTruthy(); + }); + + it('should handle viewers with known logos', async () => { + // Test that viewers with known logo files render correctly + renderDataToolLinks(); + + await waitFor( + () => { + expect(screen.getByText('Test Tools')).toBeInTheDocument(); + }, + { timeout: 3000 } + ); + + // Check that images are rendered + const images = screen.getAllByRole('img'); + + // Check for neuroglancer logo (known viewer with logo) + const neuroglancerLogo = images.find( + img => img.getAttribute('alt') === 'Neuroglancer logo' + ); + expect(neuroglancerLogo).toBeTruthy(); + expect(neuroglancerLogo?.getAttribute('src')).toContain('neuroglancer'); + + // Check for avivator logo (known viewer with logo) + const avivatorLogo = images.find( + img => img.getAttribute('alt') === 'Avivator logo' + ); + expect(avivatorLogo).toBeTruthy(); + expect(avivatorLogo?.getAttribute('src')).toContain('avivator'); + }); + }); + + describe('Custom viewer without ome_zarr_versions', () => { + it('should exclude viewer URL when set to null in OpenWithToolUrls', async () => { + const urls: OpenWithToolUrls = { + copy: 'http://localhost:3000/copy', + neuroglancer: 'http://localhost:3000/neuroglancer', + customviewer: null // Custom viewer not compatible (explicitly null) + }; + + renderDataToolLinks(urls); + + await waitFor( + () => { + expect(screen.getByText('Test Tools')).toBeInTheDocument(); + }, + { timeout: 3000 } + ); + + // Should have neuroglancer logo and copy icon + const images = screen.getAllByRole('img'); + expect(images.length).toBeGreaterThanOrEqual(2); + + // Check for neuroglancer logo + const neuroglancerLogo = images.find( + img => img.getAttribute('alt') === 'Neuroglancer logo' + ); + expect(neuroglancerLogo).toBeTruthy(); + + // Check for copy icon + const copyIcon = images.find( + img => img.getAttribute('alt') === 'Copy URL icon' + ); + expect(copyIcon).toBeTruthy(); + }); + }); + + describe('Component behavior with null urls', () => { + it('should render nothing when urls is null', () => { + renderDataToolLinks(null); + + // Component should not render when urls is null + expect(screen.queryByText('Test Tools')).not.toBeInTheDocument(); + }); + }); +}); + +describe('DataToolLinks - Expected Behavior', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + describe('Component behavior with valid viewers', () => { + it('should render valid viewer icons and copy icon', async () => { + renderDataToolLinks(); + + await waitFor( + () => { + expect(screen.getByText('Test Tools')).toBeInTheDocument(); + }, + { timeout: 3000 } + ); + + // Should render copy icon at minimum + const images = screen.getAllByRole('img'); + const copyIcon = images.find( + img => img.getAttribute('alt') === 'Copy URL icon' + ); + expect(copyIcon).toBeTruthy(); + + // Should also have viewer logos + expect(images.length).toBeGreaterThan(1); + }); + + it('should call onToolClick when copy icon is clicked', async () => { + const onToolClick = vi.fn(async () => {}); + renderDataToolLinks(undefined, onToolClick); + + await waitFor( + () => { + expect(screen.getByText('Test Tools')).toBeInTheDocument(); + }, + { timeout: 3000 } + ); + + // Click the copy icon (always present) + const images = screen.getAllByRole('img'); + const copyIcon = images.find( + img => img.getAttribute('alt') === 'Copy URL icon' + ); + expect(copyIcon).toBeTruthy(); + + const copyButton = copyIcon!.closest('button'); + expect(copyButton).toBeTruthy(); + + copyButton!.click(); + + await waitFor(() => { + expect(onToolClick).toHaveBeenCalledWith('copy'); + }); + }); + + it('should render multiple viewer logos when URLs are provided', async () => { + renderDataToolLinks(); + + await waitFor( + () => { + expect(screen.getByText('Test Tools')).toBeInTheDocument(); + }, + { timeout: 3000 } + ); + + const images = screen.getAllByRole('img'); + + // Should have neuroglancer, avivator, and copy icons at minimum + expect(images.length).toBeGreaterThanOrEqual(3); + + // Verify specific logos are present + const neuroglancerLogo = images.find( + img => img.getAttribute('alt') === 'Neuroglancer logo' + ); + const avivatorLogo = images.find( + img => img.getAttribute('alt') === 'Avivator logo' + ); + + expect(neuroglancerLogo).toBeTruthy(); + expect(avivatorLogo).toBeTruthy(); + }); + }); + + describe('Tooltip behavior', () => { + it('should show "Copy data URL" tooltip by default', async () => { + renderDataToolLinks(); + + await waitFor( + () => { + expect(screen.getByText('Test Tools')).toBeInTheDocument(); + }, + { timeout: 3000 } + ); + + // The copy button should have the correct aria-label + const copyButton = screen.getByLabelText('Copy data URL'); + expect(copyButton).toBeInTheDocument(); + }); + + it('should show viewer tooltip labels', async () => { + renderDataToolLinks(); + + await waitFor( + () => { + expect(screen.getByText('Test Tools')).toBeInTheDocument(); + }, + { timeout: 3000 } + ); + + // Viewer buttons should have correct aria-labels from their config + const neuroglancerButton = screen.getByLabelText('View in Neuroglancer'); + expect(neuroglancerButton).toBeInTheDocument(); + + const avivatorButton = screen.getByLabelText('View in Avivator'); + expect(avivatorButton).toBeInTheDocument(); + }); + }); +}); From d2a4e840181c78cd3855c02248b7d0e0672d2ea0 Mon Sep 17 00:00:00 2001 From: allison-truhlar Date: Tue, 3 Feb 2026 10:10:11 -0500 Subject: [PATCH 022/104] fix: add ViewersProvider to unit test setup for zarr tests --- frontend/src/__tests__/test-utils.tsx | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/frontend/src/__tests__/test-utils.tsx b/frontend/src/__tests__/test-utils.tsx index a54d0a43d..f3bcce0c6 100644 --- a/frontend/src/__tests__/test-utils.tsx +++ b/frontend/src/__tests__/test-utils.tsx @@ -15,6 +15,7 @@ import { TicketProvider } from '@/contexts/TicketsContext'; import { ProfileContextProvider } from '@/contexts/ProfileContext'; import { ExternalBucketProvider } from '@/contexts/ExternalBucketContext'; import { ServerHealthProvider } from '@/contexts/ServerHealthContext'; +import { ViewersProvider } from '@/contexts/ViewersContext'; import ErrorFallback from '@/components/ErrorFallback'; interface CustomRenderOptions extends Omit { @@ -40,13 +41,15 @@ const Browse = ({ children }: { children: ReactNode }) => { - - - - {children} - - - + + + + + {children} + + + + From b0e97a37975fc65a92f7adcf02d4867c379109e5 Mon Sep 17 00:00:00 2001 From: allison-truhlar Date: Tue, 3 Feb 2026 10:12:49 -0500 Subject: [PATCH 023/104] fix: change how logos urls are found - previously, if a logo path wasn't found, a 404 URL was still created, so it was never falling through to the fallback_logo. --- frontend/src/assets/fallback_logo.png | Bin 8198 -> 23569 bytes frontend/src/config/viewerLogos.ts | 34 +++++++++++++++++--------- 2 files changed, 23 insertions(+), 11 deletions(-) diff --git a/frontend/src/assets/fallback_logo.png b/frontend/src/assets/fallback_logo.png index f636cabbcebaf46d99572dd5c77da3abe3d99525..da5fcc42a33889f495302c8948d7c5a3da4cce22 100644 GIT binary patch literal 23569 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�kwES{9sV8F&)~R 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 literal 8198 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 diff --git a/frontend/src/config/viewerLogos.ts b/frontend/src/config/viewerLogos.ts index e22fdb29b..cbc13369e 100644 --- a/frontend/src/config/viewerLogos.ts +++ b/frontend/src/config/viewerLogos.ts @@ -1,9 +1,21 @@ import fallback_logo from '@/assets/fallback_logo.png'; /** - * Fallback logo for viewers without a specified logo + * Map of all available logo files in the assets directory + * This is populated at build time by Vite's glob import */ -export const FALLBACK_LOGO = fallback_logo; +const LOGO_MODULES = import.meta.glob<{ default: string }>('@/assets/*.png', { + eager: true +}); + +/** + * Extract filename from glob import path + * Converts '/src/assets/neuroglancer.png' to 'neuroglancer.png' + */ +function extractFileName(path: string): string { + const parts = path.split('/'); + return parts[parts.length - 1]; +} /** * Get logo path for a viewer @@ -22,14 +34,14 @@ export function getViewerLogo( ): string { const logoFileName = customLogoPath || `${viewerName.toLowerCase()}.png`; - try { - // Try to dynamically import the logo from assets - // This will be resolved at build time by Vite - const logo = new URL(`../assets/${logoFileName}`, import.meta.url).href; - return logo; - // eslint-disable-next-line @typescript-eslint/no-unused-vars - } catch (error) { - // If logo not found, return fallback - return FALLBACK_LOGO; + // Search through available logos + for (const [path, module] of Object.entries(LOGO_MODULES)) { + const fileName = extractFileName(path); + if (fileName === logoFileName) { + return module.default; + } } + + // If logo not found, return fallback + return fallback_logo; } From 8b2af824831018fed7b472c7a3ec8476b8c09713 Mon Sep 17 00:00:00 2001 From: allison-truhlar Date: Tue, 3 Feb 2026 10:13:41 -0500 Subject: [PATCH 024/104] tests: add unit tests for the getViewerLogo func - includes checking that a fallback_logo is used when no logo is found for a viewer --- .../componentTests/DataToolLinks.test.tsx | 19 +--- .../__tests__/unitTests/viewerLogos.test.ts | 100 ++++++++++++++++++ 2 files changed, 103 insertions(+), 16 deletions(-) create mode 100644 frontend/src/__tests__/unitTests/viewerLogos.test.ts diff --git a/frontend/src/__tests__/componentTests/DataToolLinks.test.tsx b/frontend/src/__tests__/componentTests/DataToolLinks.test.tsx index 90cd8257a..9fc82e4c3 100644 --- a/frontend/src/__tests__/componentTests/DataToolLinks.test.tsx +++ b/frontend/src/__tests__/componentTests/DataToolLinks.test.tsx @@ -147,22 +147,9 @@ describe('DataToolLinks - Edge Cases', () => { vi.clearAllMocks(); }); - describe('Fallback logo handling', () => { - it('should handle viewers with custom logos', async () => { - // Test that getViewerLogo returns appropriate path - const { getViewerLogo } = await import('@/config/viewerLogos'); - - // Test with known viewer - const neuroglancerLogo = getViewerLogo('neuroglancer'); - expect(neuroglancerLogo).toBeTruthy(); - - // Test with custom logo path - const customLogo = getViewerLogo('custom-viewer', 'custom-logo.png'); - expect(customLogo).toBeTruthy(); - }); - - it('should handle viewers with known logos', async () => { - // Test that viewers with known logo files render correctly + describe('Logo rendering in components', () => { + it('should render viewer logos in component', async () => { + // Test that viewers with known logo files render correctly in the component renderDataToolLinks(); await waitFor( diff --git a/frontend/src/__tests__/unitTests/viewerLogos.test.ts b/frontend/src/__tests__/unitTests/viewerLogos.test.ts new file mode 100644 index 000000000..60f491d67 --- /dev/null +++ b/frontend/src/__tests__/unitTests/viewerLogos.test.ts @@ -0,0 +1,100 @@ +import { describe, it, expect } from 'vitest'; +import { getViewerLogo } from '@/config/viewerLogos'; +import fallback_logo from '@/assets/fallback_logo.png'; + +describe('getViewerLogo', () => { + describe('Existing logo files', () => { + it('should return logo path for viewer with existing logo file', () => { + const neuroglancerLogo = getViewerLogo('neuroglancer'); + expect(neuroglancerLogo).toBeTruthy(); + expect(neuroglancerLogo).not.toBe(fallback_logo); + }); + + it('should return logo path for avivator', () => { + const avivatorLogo = getViewerLogo('avivator'); + expect(avivatorLogo).toBeTruthy(); + expect(avivatorLogo).not.toBe(fallback_logo); + }); + + it('should return logo path for validator', () => { + const validatorLogo = getViewerLogo('validator'); + expect(validatorLogo).toBeTruthy(); + expect(validatorLogo).not.toBe(fallback_logo); + }); + + it('should return logo path for vole', () => { + const voleLogo = getViewerLogo('vole'); + expect(voleLogo).toBeTruthy(); + expect(voleLogo).not.toBe(fallback_logo); + }); + }); + + describe('Custom logo paths', () => { + it('should return logo when custom logo path exists', () => { + // Using an existing logo file as a custom path + const customLogo = getViewerLogo('any-name', 'neuroglancer.png'); + expect(customLogo).toBeTruthy(); + expect(customLogo).not.toBe(fallback_logo); + }); + + it('should return fallback when custom logo path does not exist', () => { + const nonExistentCustomLogo = getViewerLogo('test', 'nonexistent.png'); + expect(nonExistentCustomLogo).toBe(fallback_logo); + }); + }); + + describe('Fallback logo handling', () => { + it('should return fallback logo when viewer logo file does not exist', () => { + const nonExistentViewerLogo = getViewerLogo('nonexistent_viewer'); + expect(nonExistentViewerLogo).toBe(fallback_logo); + }); + + it('should return fallback logo for custom_viewer without logo file', () => { + const customViewerLogo = getViewerLogo('custom_viewer'); + expect(customViewerLogo).toBe(fallback_logo); + }); + + it('should return fallback logo for unknown viewer names', () => { + const unknownLogo = getViewerLogo('unknown_test_viewer_xyz'); + expect(unknownLogo).toBe(fallback_logo); + }); + }); + + describe('Case handling', () => { + it('should handle lowercase viewer names', () => { + const logo = getViewerLogo('neuroglancer'); + expect(logo).toBeTruthy(); + expect(logo).not.toBe(fallback_logo); + }); + + it('should convert uppercase to lowercase for logo lookup', () => { + // getViewerLogo converts to lowercase, so 'NEUROGLANCER' -> 'neuroglancer.png' + const logo = getViewerLogo('NEUROGLANCER'); + expect(logo).toBeTruthy(); + expect(logo).not.toBe(fallback_logo); + }); + + it('should handle mixed case viewer names', () => { + const logo = getViewerLogo('NeuroGlancer'); + expect(logo).toBeTruthy(); + expect(logo).not.toBe(fallback_logo); + }); + }); + + describe('Edge cases', () => { + it('should handle empty string viewer name', () => { + const emptyLogo = getViewerLogo(''); + expect(emptyLogo).toBe(fallback_logo); + }); + + it('should handle viewer names with special characters', () => { + const specialLogo = getViewerLogo('viewer-with-dashes'); + expect(specialLogo).toBe(fallback_logo); + }); + + it('should handle viewer names with underscores', () => { + const underscoreLogo = getViewerLogo('viewer_with_underscores'); + expect(underscoreLogo).toBe(fallback_logo); + }); + }); +}); \ No newline at end of file From 1ebfb3eae88c3d02a2d0d8774f67346c3ffc2483 Mon Sep 17 00:00:00 2001 From: allison-truhlar Date: Tue, 3 Feb 2026 10:14:20 -0500 Subject: [PATCH 025/104] chore: prettier formatting --- frontend/src/__tests__/unitTests/viewerLogos.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/__tests__/unitTests/viewerLogos.test.ts b/frontend/src/__tests__/unitTests/viewerLogos.test.ts index 60f491d67..2b0c80001 100644 --- a/frontend/src/__tests__/unitTests/viewerLogos.test.ts +++ b/frontend/src/__tests__/unitTests/viewerLogos.test.ts @@ -97,4 +97,4 @@ describe('getViewerLogo', () => { expect(underscoreLogo).toBe(fallback_logo); }); }); -}); \ No newline at end of file +}); From 5cacb679b9aabf5579b33e5b650608ad75e69431 Mon Sep 17 00:00:00 2001 From: allison-truhlar Date: Tue, 3 Feb 2026 12:49:59 -0500 Subject: [PATCH 026/104] refactor: move valid ome zarr versions to config file - this minimizes the number of files we need to edit to change anything related to the viewers --- frontend/src/config/viewers.config.yaml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/frontend/src/config/viewers.config.yaml b/frontend/src/config/viewers.config.yaml index 6beb3e7b5..756eccb8c 100644 --- a/frontend/src/config/viewers.config.yaml +++ b/frontend/src/config/viewers.config.yaml @@ -4,6 +4,9 @@ # The @bioimagetools/capability-manifest library is used to determine compatibility # for viewers that have a capability manifest. # +# Global Configuration: +# - valid_ome_zarr_versions: Array of OME-Zarr versions supported by the application (e.g., `[0.4, 0.5]`) +# # For viewers with capability manifests, you must provide: # - name: must match name value in capability manifest # Optionally: @@ -19,6 +22,9 @@ # - logo: Filename of logo in `frontend/src/assets/` (defaults to `{name}.png` if not specified) # - label: Custom tooltip text (defaults to "View in {Name}") +# Valid OME-Zarr versions supported by this application +valid_ome_zarr_versions: [0.4, 0.5] + viewers: # OME-Zarr viewers with capability manifests - name: neuroglancer From 7ec34eca68c24ad9abc6740eeecab11556429625 Mon Sep 17 00:00:00 2001 From: allison-truhlar Date: Tue, 3 Feb 2026 12:51:15 -0500 Subject: [PATCH 027/104] refactor: add zod for viewer config validation - still requires some custom validation related to the capability manifests --- frontend/package-lock.json | 12 +- frontend/package.json | 3 +- frontend/src/config/viewersConfig.ts | 177 ++++++++++++++++----------- 3 files changed, 119 insertions(+), 73 deletions(-) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index d79efbeb8..8f9e233f9 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -34,7 +34,8 @@ "react-syntax-highlighter": "^16.1.0", "shepherd.js": "^14.5.1", "tailwindcss": "^3.4.17", - "zarrita": "^0.5.1" + "zarrita": "^0.5.1", + "zod": "^4.3.6" }, "devDependencies": { "@eslint/css": "^0.8.1", @@ -11119,6 +11120,15 @@ "numcodecs": "^0.3.2" } }, + "node_modules/zod": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", + "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, "node_modules/zwitch": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", diff --git a/frontend/package.json b/frontend/package.json index 6810c720b..48c6c7bef 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -55,7 +55,8 @@ "react-syntax-highlighter": "^16.1.0", "shepherd.js": "^14.5.1", "tailwindcss": "^3.4.17", - "zarrita": "^0.5.1" + "zarrita": "^0.5.1", + "zod": "^4.3.6" }, "devDependencies": { "@eslint/css": "^0.8.1", diff --git a/frontend/src/config/viewersConfig.ts b/frontend/src/config/viewersConfig.ts index 4507e9c67..fccabaafa 100644 --- a/frontend/src/config/viewersConfig.ts +++ b/frontend/src/config/viewersConfig.ts @@ -1,27 +1,72 @@ import yaml from 'js-yaml'; +import { z } from 'zod'; /** - * Viewer entry from viewers.config.yaml + * Zod schema for viewer entry from viewers.config.yaml */ -export interface ViewerConfigEntry { - name: string; - url?: string; - label?: string; - logo?: string; - ome_zarr_versions?: number[]; -} +const ViewerConfigEntrySchema = z.object( + { + name: z.string({ + message: 'Each viewer must have a "name" field (string)' + }), + url: z.string({ message: '"url" must be a string' }).optional(), + label: z.string({ message: '"label" must be a string' }).optional(), + logo: z.string({ message: '"logo" must be a string' }).optional(), + ome_zarr_versions: z + .array(z.number(), { message: '"ome_zarr_versions" must be an array' }) + .optional() + }, + { + error: iss => { + // When the viewer entry itself isn't an object + if (iss.code === 'invalid_type' && iss.expected === 'object') { + return 'Each viewer must have a "name" field (string)'; + } + // Return undefined to use default behavior for other errors + return undefined; + } + } +); /** - * Structure of viewers.config.yaml + * Zod schema for viewers.config.yaml structure */ -export interface ViewersConfigYaml { - viewers: ViewerConfigEntry[]; -} +const ViewersConfigYamlSchema = z.object( + { + valid_ome_zarr_versions: z + .array( + z.number({ + message: '"valid_ome_zarr_versions" must contain only numbers' + }), + { + message: + 'Configuration must have a "valid_ome_zarr_versions" field containing an array of numbers' + } + ) + .min(1, { + message: '"valid_ome_zarr_versions" must not be empty' + }), + viewers: z.array(ViewerConfigEntrySchema, { + message: + 'Configuration must have a "viewers" field containing an array of viewers' + }) + }, + { + error: iss => { + if (iss.code === 'invalid_type') { + return { + message: + 'Configuration must have "valid_ome_zarr_versions" and "viewers" fields' + }; + } + } + } +); -/** - * Valid OME-Zarr versions supported by the application - */ -const VALID_OME_ZARR_VERSIONS = [0.4, 0.5]; +// exported for use in ViewersContext +export type ViewerConfigEntry = z.infer; + +type ViewersConfigYaml = z.infer; /** * Parse and validate viewers configuration YAML @@ -42,91 +87,81 @@ export function parseViewersConfig( ); } - if (!parsed || typeof parsed !== 'object') { - throw new Error('Configuration must be an object'); - } + // First pass: validate basic structure with Zod + const baseValidation = ViewersConfigYamlSchema.safeParse(parsed); + + if (!baseValidation.success) { + // Extract the first error message with path context to extract viewer name if possible + const firstError = baseValidation.error.issues[0]; - const config = parsed as Record; + // Check if the error is nested within a specific viewer + if (firstError.path.length > 0 && firstError.path[0] === 'viewers') { + // Extract viewer index from path (e.g., ['viewers', 0, 'ome_zarr_versions']) + const viewerIndex = firstError.path[1]; + + if ( + typeof viewerIndex === 'number' && + parsed && + typeof parsed === 'object' + ) { + const configData = parsed as { viewers?: unknown[] }; + const viewer = configData.viewers?.[viewerIndex]; + + // Try to get viewer name if it exists + if (viewer && typeof viewer === 'object' && 'name' in viewer) { + const viewerName = (viewer as { name: unknown }).name; + if (typeof viewerName === 'string') { + throw new Error(`Viewer "${viewerName}": ${firstError.message}`); + } + } + } + } - if (!Array.isArray(config.viewers)) { - throw new Error('Configuration must have a "viewers" array'); + // Fallback to original error message + throw new Error(firstError.message); } + const config = baseValidation.data; + // Normalize viewer names for comparison (case-insensitive) const normalizedManifestViewers = viewersWithManifests.map(name => name.toLowerCase() ); - // Validate each viewer entry - for (const viewer of config.viewers) { - if (!viewer || typeof viewer !== 'object') { - throw new Error('Each viewer must be an object'); - } - - const v = viewer as Record; - - if (typeof v.name !== 'string') { - throw new Error('Each viewer must have a "name" field (string)'); - } + // Second pass: validate manifest-dependent requirements and cross-field constraints + for (let i = 0; i < config.viewers.length; i++) { + const viewer = config.viewers[i]; // Check if this viewer has a capability manifest const hasManifest = normalizedManifestViewers.includes( - v.name.toLowerCase() + viewer.name.toLowerCase() ); // If this viewer doesn't have a capability manifest, require additional fields if (!hasManifest) { - if (typeof v.url !== 'string') { + if (!viewer.url) { throw new Error( - `Viewer "${v.name}" does not have a capability manifest and must specify "url"` + `Viewer "${viewer.name}" does not have a capability manifest and must specify "url"` ); } - if ( - !Array.isArray(v.ome_zarr_versions) || - v.ome_zarr_versions.length === 0 - ) { + if (!viewer.ome_zarr_versions || viewer.ome_zarr_versions.length === 0) { throw new Error( - `Viewer "${v.name}" does not have a capability manifest and must specify "ome_zarr_versions" (array of numbers)` + `Viewer "${viewer.name}" does not have a capability manifest and must specify "ome_zarr_versions" (array of numbers)` ); } } - // Validate optional fields if present - if (v.url !== undefined && typeof v.url !== 'string') { - throw new Error(`Viewer "${v.name}": "url" must be a string`); - } - if (v.label !== undefined && typeof v.label !== 'string') { - throw new Error(`Viewer "${v.name}": "label" must be a string`); - } - if (v.logo !== undefined && typeof v.logo !== 'string') { - throw new Error(`Viewer "${v.name}": "logo" must be a string`); - } - if ( - v.ome_zarr_versions !== undefined && - !Array.isArray(v.ome_zarr_versions) - ) { - throw new Error( - `Viewer "${v.name}": "ome_zarr_versions" must be an array` - ); - } - // Validate ome_zarr_versions values if present - if ( - v.ome_zarr_versions !== undefined && - Array.isArray(v.ome_zarr_versions) - ) { - for (const version of v.ome_zarr_versions) { - if (!VALID_OME_ZARR_VERSIONS.includes(version)) { + if (viewer.ome_zarr_versions) { + for (const version of viewer.ome_zarr_versions) { + if (!config.valid_ome_zarr_versions.includes(version)) { throw new Error( - `Viewer "${v.name}": invalid ome_zarr_version "${version}". Valid versions are: ${VALID_OME_ZARR_VERSIONS.join(', ')}` + `Viewer "${viewer.name}": invalid ome_zarr_version "${version}". Valid versions are: ${config.valid_ome_zarr_versions.join(', ')}` ); } } } } - // Type assertion is safe here because we've performed comprehensive runtime validation above. - // TypeScript sees 'config' as Record but our validation ensures it matches - // ViewersConfigYaml structure. The intermediate 'unknown' cast is required for type compatibility. - return config as unknown as ViewersConfigYaml; + return config; } From 30f0853f91c4b2df69ab12a99f1864a2a5eeb616 Mon Sep 17 00:00:00 2001 From: allison-truhlar Date: Tue, 3 Feb 2026 12:51:55 -0500 Subject: [PATCH 028/104] refactor: update tests for ome zarr version in config; zod error msgs --- .../componentTests/DataToolLinks.test.tsx | 2 + .../__tests__/unitTests/viewersConfig.test.ts | 117 ++++++++++++++++-- 2 files changed, 109 insertions(+), 10 deletions(-) diff --git a/frontend/src/__tests__/componentTests/DataToolLinks.test.tsx b/frontend/src/__tests__/componentTests/DataToolLinks.test.tsx index 9fc82e4c3..b3d0d8a15 100644 --- a/frontend/src/__tests__/componentTests/DataToolLinks.test.tsx +++ b/frontend/src/__tests__/componentTests/DataToolLinks.test.tsx @@ -115,6 +115,7 @@ describe('DataToolLinks - Error Scenarios', () => { const { parseViewersConfig } = await import('@/config/viewersConfig'); const configMissingUrl = ` +valid_ome_zarr_versions: [0.4, 0.5] viewers: - name: custom-viewer # Missing url for viewer without manifest @@ -129,6 +130,7 @@ viewers: const { parseViewersConfig } = await import('@/config/viewersConfig'); const configMissingVersions = ` +valid_ome_zarr_versions: [0.4, 0.5] viewers: - name: custom-viewer url: https://example.com diff --git a/frontend/src/__tests__/unitTests/viewersConfig.test.ts b/frontend/src/__tests__/unitTests/viewersConfig.test.ts index 815c31406..fae881b1a 100644 --- a/frontend/src/__tests__/unitTests/viewersConfig.test.ts +++ b/frontend/src/__tests__/unitTests/viewersConfig.test.ts @@ -5,19 +5,36 @@ describe('parseViewersConfig', () => { describe('Valid configurations', () => { it('should parse valid config with viewers that have manifests', () => { const yaml = ` +valid_ome_zarr_versions: [0.4, 0.5] viewers: - name: neuroglancer - name: avivator `; const result = parseViewersConfig(yaml, ['neuroglancer', 'avivator']); + expect(result.valid_ome_zarr_versions).toEqual([0.4, 0.5]); expect(result.viewers).toHaveLength(2); expect(result.viewers[0].name).toBe('neuroglancer'); expect(result.viewers[1].name).toBe('avivator'); }); + it('should support custom valid_ome_zarr_versions', () => { + const yaml = ` +valid_ome_zarr_versions: [0.4, 0.5, 0.6] +viewers: + - name: custom-viewer + url: https://example.com/{dataLink} + ome_zarr_versions: [0.6] +`; + const result = parseViewersConfig(yaml, []); + + expect(result.valid_ome_zarr_versions).toEqual([0.4, 0.5, 0.6]); + expect(result.viewers[0].ome_zarr_versions).toEqual([0.6]); + }); + it('should parse config with custom viewer with all required fields', () => { const yaml = ` +valid_ome_zarr_versions: [0.4, 0.5] viewers: - name: custom-viewer url: https://example.com/{dataLink} @@ -33,6 +50,7 @@ viewers: it('should parse config with optional fields', () => { const yaml = ` +valid_ome_zarr_versions: [0.4, 0.5] viewers: - name: custom-viewer url: https://example.com/{dataLink} @@ -48,6 +66,7 @@ viewers: it('should allow viewer with manifest to override url', () => { const yaml = ` +valid_ome_zarr_versions: [0.4, 0.5] viewers: - name: neuroglancer url: https://custom-neuroglancer.com/{dataLink} @@ -61,6 +80,7 @@ viewers: it('should parse mixed config with manifest and non-manifest viewers', () => { const yaml = ` +valid_ome_zarr_versions: [0.4, 0.5] viewers: - name: neuroglancer - name: custom-viewer @@ -91,7 +111,7 @@ viewers: // js-yaml parses this as a string, which then fails the object check expect(() => parseViewersConfig(invalidYaml, [])).toThrow( - /Configuration must be an object/ + /Configuration must have "valid_ome_zarr_versions" and "viewers" fields/ ); }); @@ -99,7 +119,7 @@ viewers: const invalidYaml = 'just a string'; expect(() => parseViewersConfig(invalidYaml, [])).toThrow( - /Configuration must be an object/ + /Configuration must have "valid_ome_zarr_versions" and "viewers" fields/ ); }); @@ -107,46 +127,97 @@ viewers: const invalidYaml = ''; expect(() => parseViewersConfig(invalidYaml, [])).toThrow( - /Configuration must be an object/ + /Configuration must have "valid_ome_zarr_versions" and "viewers" fields/ ); }); }); describe('Missing required fields', () => { + it('should throw error when valid_ome_zarr_versions is missing', () => { + const yaml = ` +viewers: + - name: neuroglancer +`; + + expect(() => parseViewersConfig(yaml, [])).toThrow( + /Configuration must have a \"valid_ome_zarr_versions\" field containing an array of numbers/ + ); + }); + + it('should throw error when valid_ome_zarr_versions is not an array', () => { + const yaml = ` +valid_ome_zarr_versions: "not-an-array" +viewers: + - name: neuroglancer +`; + + expect(() => parseViewersConfig(yaml, [])).toThrow( + /Configuration must have a \"valid_ome_zarr_versions\" field containing an array of numbers/ + ); + }); + + it('should throw error when valid_ome_zarr_versions is empty', () => { + const yaml = ` +valid_ome_zarr_versions: [] +viewers: + - name: neuroglancer +`; + + expect(() => parseViewersConfig(yaml, [])).toThrow( + /"valid_ome_zarr_versions" must not be empty/ + ); + }); + + it('should throw error when valid_ome_zarr_versions contains non-numbers', () => { + const yaml = ` +valid_ome_zarr_versions: [0.4, "0.5"] +viewers: + - name: neuroglancer +`; + + expect(() => parseViewersConfig(yaml, [])).toThrow( + /"valid_ome_zarr_versions" must contain only numbers/ + ); + }); + it('should throw error when viewers array is missing', () => { const yaml = ` +valid_ome_zarr_versions: [0.4, 0.5] name: some-config other_field: value `; expect(() => parseViewersConfig(yaml, [])).toThrow( - /Configuration must have a "viewers" array/ + /Configuration must have a \"viewers\" field containing an array of viewers/ ); }); it('should throw error when viewers is not an array', () => { const yaml = ` +valid_ome_zarr_versions: [0.4, 0.5] viewers: not-an-array `; expect(() => parseViewersConfig(yaml, [])).toThrow( - /Configuration must have a "viewers" array/ + /Configuration must have a \"viewers\" field containing an array of viewers/ ); }); it('should throw error when viewer is not an object', () => { const yaml = ` +valid_ome_zarr_versions: [0.4, 0.5] viewers: - just-a-string `; expect(() => parseViewersConfig(yaml, [])).toThrow( - /Each viewer must be an object/ + /Each viewer must have a "name" field \(string\)/ ); }); it('should throw error when viewer lacks name field', () => { const yaml = ` +valid_ome_zarr_versions: [0.4, 0.5] viewers: - url: https://example.com ome_zarr_versions: [0.4] @@ -159,6 +230,7 @@ viewers: it('should throw error when viewer name is not a string', () => { const yaml = ` +valid_ome_zarr_versions: [0.4, 0.5] viewers: - name: 123 url: https://example.com @@ -172,6 +244,7 @@ viewers: it('should throw error when custom viewer (no manifest) lacks url', () => { const yaml = ` +valid_ome_zarr_versions: [0.4, 0.5] viewers: - name: custom-viewer ome_zarr_versions: [0.4] @@ -184,18 +257,20 @@ viewers: it('should throw error when custom viewer (no manifest) lacks ome_zarr_versions', () => { const yaml = ` +valid_ome_zarr_versions: [0.4, 0.5] viewers: - name: custom-viewer url: https://example.com/{dataLink} `; expect(() => parseViewersConfig(yaml, [])).toThrow( - /Viewer "custom-viewer" does not have a capability manifest and must specify "ome_zarr_versions"/ + /Viewer "custom-viewer" does not have a capability manifest and must specify "ome_zarr_versions" \(array of numbers\)/ ); }); it('should throw error when custom viewer has empty ome_zarr_versions array', () => { const yaml = ` +valid_ome_zarr_versions: [0.4, 0.5] viewers: - name: custom-viewer url: https://example.com/{dataLink} @@ -203,7 +278,7 @@ viewers: `; expect(() => parseViewersConfig(yaml, [])).toThrow( - /Viewer "custom-viewer" does not have a capability manifest and must specify "ome_zarr_versions"/ + /Viewer "custom-viewer" does not have a capability manifest and must specify "ome_zarr_versions" \(array of numbers\)/ ); }); }); @@ -211,6 +286,7 @@ viewers: describe('Invalid field types', () => { it('should throw error when url is not a string (for custom viewer)', () => { const yaml = ` +valid_ome_zarr_versions: [0.4, 0.5] viewers: - name: custom-viewer url: 123 @@ -220,12 +296,13 @@ viewers: // The required field check happens first, so if url is wrong type, // it's caught by the "must specify url" check expect(() => parseViewersConfig(yaml, [])).toThrow( - /Viewer "custom-viewer" does not have a capability manifest and must specify "url"/ + /Viewer "custom-viewer": "url" must be a string/ ); }); it('should throw error when url override is not a string (for manifest viewer)', () => { const yaml = ` +valid_ome_zarr_versions: [0.4, 0.5] viewers: - name: neuroglancer url: 123 @@ -238,6 +315,7 @@ viewers: it('should throw error when label is not a string', () => { const yaml = ` +valid_ome_zarr_versions: [0.4, 0.5] viewers: - name: custom-viewer url: https://example.com @@ -252,6 +330,7 @@ viewers: it('should throw error when logo is not a string', () => { const yaml = ` +valid_ome_zarr_versions: [0.4, 0.5] viewers: - name: custom-viewer url: https://example.com @@ -266,6 +345,7 @@ viewers: it('should throw error when ome_zarr_versions is not an array (for custom viewer)', () => { const yaml = ` +valid_ome_zarr_versions: [0.4, 0.5] viewers: - name: custom-viewer url: https://example.com @@ -274,12 +354,13 @@ viewers: // The required field check happens first and checks if it's an array expect(() => parseViewersConfig(yaml, [])).toThrow( - /Viewer "custom-viewer" does not have a capability manifest and must specify "ome_zarr_versions"/ + /Viewer "custom-viewer": "ome_zarr_versions" must be an array/ ); }); it('should throw error when ome_zarr_versions override is not an array (for manifest viewer)', () => { const yaml = ` +valid_ome_zarr_versions: [0.4, 0.5] viewers: - name: neuroglancer ome_zarr_versions: "not-an-array" @@ -294,6 +375,7 @@ viewers: describe('OME-Zarr version validation', () => { it('should accept valid ome_zarr_versions (0.4 and 0.5)', () => { const yaml = ` +valid_ome_zarr_versions: [0.4, 0.5] viewers: - name: custom-viewer url: https://example.com @@ -307,6 +389,7 @@ viewers: it('should throw error for invalid ome_zarr_version (0.3)', () => { const yaml = ` +valid_ome_zarr_versions: [0.4, 0.5] viewers: - name: custom-viewer url: https://example.com @@ -320,6 +403,7 @@ viewers: it('should throw error for invalid ome_zarr_version (1.0)', () => { const yaml = ` +valid_ome_zarr_versions: [0.4, 0.5] viewers: - name: custom-viewer url: https://example.com @@ -333,6 +417,7 @@ viewers: it('should throw error when mixing valid and invalid versions', () => { const yaml = ` +valid_ome_zarr_versions: [0.4, 0.5] viewers: - name: custom-viewer url: https://example.com @@ -346,6 +431,7 @@ viewers: it('should throw error for invalid version in manifest viewer override', () => { const yaml = ` +valid_ome_zarr_versions: [0.4, 0.5] viewers: - name: neuroglancer ome_zarr_versions: [0.3] @@ -358,6 +444,7 @@ viewers: it('should accept only 0.4', () => { const yaml = ` +valid_ome_zarr_versions: [0.4, 0.5] viewers: - name: custom-viewer url: https://example.com @@ -371,6 +458,7 @@ viewers: it('should accept only 0.5', () => { const yaml = ` +valid_ome_zarr_versions: [0.4, 0.5] viewers: - name: custom-viewer url: https://example.com @@ -386,6 +474,7 @@ viewers: describe('Case sensitivity and normalization', () => { it('should handle case-insensitive manifest matching', () => { const yaml = ` +valid_ome_zarr_versions: [0.4, 0.5] viewers: - name: Neuroglancer - name: AVIVATOR @@ -401,6 +490,7 @@ viewers: it('should match manifests case-insensitively for mixed case', () => { const yaml = ` +valid_ome_zarr_versions: [0.4, 0.5] viewers: - name: NeuroGlancer `; @@ -417,6 +507,7 @@ viewers: describe('Edge cases', () => { it('should handle empty viewers array', () => { const yaml = ` +valid_ome_zarr_versions: [0.4, 0.5] viewers: [] `; @@ -427,6 +518,7 @@ viewers: [] it('should handle viewer with only name (has manifest)', () => { const yaml = ` +valid_ome_zarr_versions: [0.4, 0.5] viewers: - name: neuroglancer `; @@ -439,6 +531,7 @@ viewers: it('should preserve all valid fields in parsed output', () => { const yaml = ` +valid_ome_zarr_versions: [0.4, 0.5] viewers: - name: custom url: https://example.com @@ -460,6 +553,7 @@ viewers: it('should handle multiple valid ome_zarr_versions', () => { const yaml = ` +valid_ome_zarr_versions: [0.4, 0.5] viewers: - name: custom url: https://example.com @@ -473,6 +567,7 @@ viewers: it('should handle single ome_zarr_version in array', () => { const yaml = ` +valid_ome_zarr_versions: [0.4, 0.5] viewers: - name: custom url: https://example.com @@ -488,6 +583,7 @@ viewers: describe('Default parameter behavior', () => { it('should use empty array as default for viewersWithManifests', () => { const yaml = ` +valid_ome_zarr_versions: [0.4, 0.5] viewers: - name: custom url: https://example.com @@ -503,6 +599,7 @@ viewers: it('should treat viewer as non-manifest when viewersWithManifests is empty', () => { const yaml = ` +valid_ome_zarr_versions: [0.4, 0.5] viewers: - name: neuroglancer `; From bc867c81ca1a15056aea601323bdf16a16fcb319 Mon Sep 17 00:00:00 2001 From: allison-truhlar Date: Tue, 3 Feb 2026 13:06:02 -0500 Subject: [PATCH 029/104] docs: add valid_ome_zarr_versions to viewer config docs --- docs/ViewersConfiguration.md | 49 ++++++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/docs/ViewersConfiguration.md b/docs/ViewersConfiguration.md index 324b29b4e..ae0d56e21 100644 --- a/docs/ViewersConfiguration.md +++ b/docs/ViewersConfiguration.md @@ -24,6 +24,29 @@ The configuration file is located at `frontend/src/config/viewers.config.yaml`. The default configuration includes Neuroglancer, Avivator, OME-Zarr Validator, and Vol-E viewers. You can modify this file to add, remove, or customize viewers for your deployment. +## Configuration Structure + +### Global Configuration + +At the top level of the YAML file, you must specify: + +- `valid_ome_zarr_versions`: Array of OME-Zarr versions supported by the application (e.g., `[0.4, 0.5]`) + - **Required field** - must be present and cannot be empty + - This defines which OME-Zarr versions are valid across all viewers + - Individual viewer `ome_zarr_versions` will be validated against this list + - **Default value**: `[0.4, 0.5]` (set in the default config file) + +### Example: + +```yaml +# Valid OME-Zarr versions supported by this application +valid_ome_zarr_versions: [0.4, 0.5] + +viewers: + - name: neuroglancer + # ... more viewers +``` + ## Viewer Types ### Viewers with Capability Manifests (Recommended) @@ -37,6 +60,7 @@ For viewers without capability manifests, you must provide: - `name`: Viewer identifier - `url`: URL template (use `{dataLink}` placeholder for dataset URL) - `ome_zarr_versions`: Array of supported OME-Zarr versions (e.g., `[0.4, 0.5]`) + - These values must be in the `valid_ome_zarr_versions` list Optionally: @@ -48,6 +72,8 @@ Optionally: ### Enable default viewers ```yaml +valid_ome_zarr_versions: [0.4, 0.5] + viewers: - name: neuroglancer - name: avivator @@ -56,6 +82,8 @@ viewers: ### Override viewer URL ```yaml +valid_ome_zarr_versions: [0.4, 0.5] + viewers: - name: avivator url: "https://my-avivator-instance.example.com/?image_url={dataLink}" @@ -64,6 +92,8 @@ viewers: ### Add custom viewer (with convention-based logo) ```yaml +valid_ome_zarr_versions: [0.4, 0.5] + viewers: - name: my-viewer url: "https://viewer.example.com/?data={dataLink}" @@ -75,6 +105,8 @@ viewers: ### Add custom viewer (with explicit logo) ```yaml +valid_ome_zarr_versions: [0.4, 0.5] + viewers: - name: my-viewer url: "https://viewer.example.com/?data={dataLink}" @@ -83,6 +115,19 @@ viewers: label: "Open in My Viewer" ``` +### Supporting additional OME-Zarr versions + +If you want to support additional OME-Zarr versions beyond 0.4 and 0.5: + +```yaml +valid_ome_zarr_versions: [0.4, 0.5, 0.6] + +viewers: + - name: my-viewer + url: "https://viewer.example.com/?data={dataLink}" + ome_zarr_versions: [0.5, 0.6] # Only supports newer versions +``` + ## Adding Custom Viewer Logos Logo resolution follows this order: @@ -96,6 +141,8 @@ Logo resolution follows this order: **Using the naming convention (recommended):** ```yaml +valid_ome_zarr_versions: [0.4, 0.5] + viewers: - name: my-viewer # Logo will automatically resolve to @/assets/my-viewer.png @@ -106,6 +153,8 @@ Just add `frontend/src/assets/my-viewer.png` - no config needed! **Using a custom logo filename:** ```yaml +valid_ome_zarr_versions: [0.4, 0.5] + viewers: - name: my-viewer logo: "custom-logo.png" # Will use @/assets/custom-logo.png From 83dd34f56caada1877823f2fbafd72129fd61099 Mon Sep 17 00:00:00 2001 From: allison-truhlar Date: Tue, 3 Feb 2026 15:25:19 -0500 Subject: [PATCH 030/104] refactor: move metadata.multiscale check back to useZarrMetadata hook --- frontend/src/contexts/ViewersContext.tsx | 8 -- frontend/src/hooks/useZarrMetadata.ts | 114 +++++++++++++---------- 2 files changed, 67 insertions(+), 55 deletions(-) diff --git a/frontend/src/contexts/ViewersContext.tsx b/frontend/src/contexts/ViewersContext.tsx index a2abc4e1a..3478010b2 100644 --- a/frontend/src/contexts/ViewersContext.tsx +++ b/frontend/src/contexts/ViewersContext.tsx @@ -218,7 +218,6 @@ export function ViewersProvider({ return compatibleNames.includes(viewer.manifest.viewer.name); } else { // Manual version check for viewers without manifests - // Custom viewers require both correct ome-zarr version AND OME metadata (multiscales) const zarrVersion = metadata.version ? parseFloat(metadata.version) : null; @@ -226,13 +225,6 @@ export function ViewersProvider({ return false; } - // Check if dataset has OME metadata (multiscales array) - const hasOmeMetadata = - metadata.multiscales && metadata.multiscales.length > 0; - if (!hasOmeMetadata) { - return false; - } - return viewer.supportedVersions.includes(zarrVersion); } }); diff --git a/frontend/src/hooks/useZarrMetadata.ts b/frontend/src/hooks/useZarrMetadata.ts index d009c0a04..744ca0d7a 100644 --- a/frontend/src/hooks/useZarrMetadata.ts +++ b/frontend/src/hooks/useZarrMetadata.ts @@ -108,8 +108,8 @@ export default function useZarrMetadata() { // Get compatible viewers for this dataset let compatibleViewers = validViewers; - // If we have metadata, use capability checking to filter - if (metadata) { + // If we have multiscales metadata (OME-Zarr), use capability checking to filter + if (metadata?.multiscale) { // Convert our metadata to OmeZarrMetadata format for capability checking const omeZarrMetadata = { version: effectiveZarrVersion === 3 ? '0.5' : '0.4', @@ -120,35 +120,32 @@ export default function useZarrMetadata() { } as any; // Type assertion needed due to internal type differences compatibleViewers = getCompatibleViewers(omeZarrMetadata); - } - - // Create a Set for lookup of compatible viewer keys - // Needed to mark incompatible but valid (as defined by the viewer config) viewers as null in openWithToolUrls - const compatibleKeys = new Set(compatibleViewers.map(v => v.key)); - for (const viewer of validViewers) { - if (!compatibleKeys.has(viewer.key)) { - openWithToolUrls[viewer.key] = null; - } - } + // Create a Set for lookup of compatible viewer keys + // Needed to mark incompatible but valid (as defined by the viewer config) viewers as null in openWithToolUrls + const compatibleKeys = new Set(compatibleViewers.map(v => v.key)); - // For compatible viewers, generate URLs - for (const viewer of compatibleViewers) { - if (!url) { - // Compatible but no data URL yet - show as available (empty string) - openWithToolUrls[viewer.key] = ''; - continue; + for (const viewer of validViewers) { + if (!compatibleKeys.has(viewer.key)) { + openWithToolUrls[viewer.key] = null; + } } - // Generate the viewer URL - let viewerUrl = viewer.urlTemplate; + // For compatible viewers, generate URLs + for (const viewer of compatibleViewers) { + if (!url) { + // Compatible but no data URL yet - show as available (empty string) + openWithToolUrls[viewer.key] = ''; + continue; + } - // Special handling for Neuroglancer to maintain existing state generation logic - if (viewer.key === 'neuroglancer') { - const neuroglancerBaseUrl = viewer.urlTemplate; + // Generate the viewer URL + let viewerUrl = viewer.urlTemplate; - if (metadata?.multiscale) { - // OME-Zarr with multiscales + // Special handling for Neuroglancer to maintain existing state generation logic + if (viewer.key === 'neuroglancer') { + // Extract base URL from template (everything before #!) + const neuroglancerBaseUrl = viewer.urlTemplate.split('#!')[0] + '#!'; if (disableNeuroglancerStateGeneration) { viewerUrl = neuroglancerBaseUrl + @@ -178,34 +175,57 @@ export default function useZarrMetadata() { } } } else { - // Non-OME Zarr array - if (disableNeuroglancerStateGeneration) { - viewerUrl = - neuroglancerBaseUrl + - generateNeuroglancerStateForDataURL(url, effectiveZarrVersion); - } else if (layerType) { - viewerUrl = - neuroglancerBaseUrl + - generateNeuroglancerStateForZarrArray( - url, - effectiveZarrVersion, - layerType - ); + // For other viewers, replace {dataLink} placeholder if present + if (viewerUrl.includes('{dataLink}')) { + viewerUrl = viewerUrl.replace( + /{dataLink}/g, + encodeURIComponent(url) + ); + } else { + // If no placeholder, use buildUrl with 'url' query param + viewerUrl = buildUrl(viewerUrl, null, { url }); } } - } else { - // For other viewers, replace {dataLink} placeholder if present - if (viewerUrl.includes('{dataLink}')) { - viewerUrl = viewerUrl.replace(/{dataLink}/g, encodeURIComponent(url)); + + openWithToolUrls[viewer.key] = viewerUrl; + } + } else { + // Non-OME Zarr - only Neuroglancer available + // Mark all non-Neuroglancer viewers as incompatible + for (const viewer of validViewers) { + if (viewer.key !== 'neuroglancer') { + openWithToolUrls[viewer.key] = null; } else { - // If no placeholder, use buildUrl with 'url' query param - viewerUrl = buildUrl(viewerUrl, null, { url }); + // Neuroglancer + if (url) { + // Extract base URL from template (everything before #!) + const neuroglancerBaseUrl = + viewer.urlTemplate.split('#!')[0] + '#!'; + if (disableNeuroglancerStateGeneration) { + openWithToolUrls.neuroglancer = + neuroglancerBaseUrl + + generateNeuroglancerStateForDataURL(url, effectiveZarrVersion); + } else if (layerType) { + openWithToolUrls.neuroglancer = + neuroglancerBaseUrl + + generateNeuroglancerStateForZarrArray( + url, + effectiveZarrVersion, + layerType + ); + } else { + // layerType not yet determined - use fallback + openWithToolUrls.neuroglancer = + neuroglancerBaseUrl + + generateNeuroglancerStateForDataURL(url, effectiveZarrVersion); + } + } else { + // No proxied URL - show Neuroglancer as available but empty + openWithToolUrls.neuroglancer = ''; + } } } - - openWithToolUrls[viewer.key] = viewerUrl; } - return openWithToolUrls; }, [ metadata, From da8dd86a93106b4788d961a22559e080aca68c4c Mon Sep 17 00:00:00 2001 From: allison-truhlar Date: Mon, 9 Feb 2026 21:39:26 +0000 Subject: [PATCH 031/104] feat: add capability manifest files for all viewers --- frontend/public/viewers/neuroglancer.yaml | 41 +++++++++++++++++++++++ frontend/public/viewers/validator.yaml | 41 +++++++++++++++++++++++ frontend/public/viewers/vizarr.yaml | 41 +++++++++++++++++++++++ frontend/public/viewers/vole.yaml | 41 +++++++++++++++++++++++ 4 files changed, 164 insertions(+) create mode 100644 frontend/public/viewers/neuroglancer.yaml create mode 100644 frontend/public/viewers/validator.yaml create mode 100644 frontend/public/viewers/vizarr.yaml create mode 100644 frontend/public/viewers/vole.yaml diff --git a/frontend/public/viewers/neuroglancer.yaml b/frontend/public/viewers/neuroglancer.yaml new file mode 100644 index 000000000..9b395a8e5 --- /dev/null +++ b/frontend/public/viewers/neuroglancer.yaml @@ -0,0 +1,41 @@ +viewer: + name: "Neuroglancer" + version: "2.41.2" + repo: "https://github.com/google/neuroglancer" + template_url: https://neuroglancer-demo.appspot.com/#!{"layers":[{"name":"image","source":"{DATA_URL}","type":"image"}]} + +capabilities: + # Enumeration of OME-Zarr versions that can be loaded + ome_zarr_versions: [0.4, 0.5] + + compression_codecs: ["blosc", "zstd", "zlib", "lz4", "gzip"] + + # Which additional RFCs are supported? + rfcs_supported: [] + + # Are axis names and units respected? + axes: true + + # Are scaling factors respected on multiscales? + scale: true + + # Are translation factors respected on multiscales (including subpixel offsets for lower scale levels)? + translation: true + + # Does the tool support multiple channels? + channels: true + + # Does the tool support multiple timepoints? + timepoints: true + + # Are labels loaded when available? + labels: false + + # Are HCS plates loaded when available? + hcs_plates: false + + # Does the viewer handle multiple images in a bioformats2raw layout? + bioformats2raw_layout: false + + # Is the OMERO metadata used to e.g. color the channels? + omero_metadata: false diff --git a/frontend/public/viewers/validator.yaml b/frontend/public/viewers/validator.yaml new file mode 100644 index 000000000..58b15d595 --- /dev/null +++ b/frontend/public/viewers/validator.yaml @@ -0,0 +1,41 @@ +viewer: + name: "validator" + version: "1.0.0" + repo: "https://github.com/ome/ome-ngff-validator" + template_url: "https://ome.github.io/ome-ngff-validator/?source={DATA_URL}" + +capabilities: + # Enumeration of OME-Zarr versions that can be loaded + ome_zarr_versions: [0.4, 0.5] + + compression_codecs: [] + + # Which additional RFCs are supported? + rfcs_supported: [] + + # Are axis names and units respected? + axes: true + + # Are scaling factors respected on multiscales? + scale: true + + # Are translation factors respected on multiscales (including subpixel offsets for lower scale levels)? + translation: true + + # Does the tool support multiple channels? + channels: true + + # Does the tool support multiple timepoints? + timepoints: true + + # Are labels loaded when available? + labels: true + + # Are HCS plates loaded when available? + hcs_plates: true + + # Does the viewer handle multiple images in a bioformats2raw layout? + bioformats2raw_layout: false + + # Is the OMERO metadata used to e.g. color the channels? + omero_metadata: true diff --git a/frontend/public/viewers/vizarr.yaml b/frontend/public/viewers/vizarr.yaml new file mode 100644 index 000000000..3098d3906 --- /dev/null +++ b/frontend/public/viewers/vizarr.yaml @@ -0,0 +1,41 @@ +viewer: + name: "Avivator" + version: "0.16.1" + repo: "https://github.com/hms-dbmi/viv" + template_url: "https://avivator.gehlenborglab.org/?image_url={DATA_URL}" + +capabilities: + # Enumeration of OME-Zarr versions that can be loaded + ome_zarr_versions: [0.4] + + compression_codecs: ["blosc", "gzip"] + + # Which additional RFCs are supported? + rfcs_supported: [] + + # Are axis names and units respected? + axes: true + + # Are scaling factors respected on multiscales? + scale: true + + # Are translation factors respected on multiscales (including subpixel offsets for lower scale levels)? + translation: true + + # Does the tool support multiple channels? + channels: true + + # Does the tool support multiple timepoints? + timepoints: true + + # Are labels loaded when available? + labels: false + + # Are HCS plates loaded when available? + hcs_plates: false + + # Does the viewer handle multiple images in a bioformats2raw layout? + bioformats2raw_layout: false + + # Is the OMERO metadata used to e.g. color the channels? + omero_metadata: true diff --git a/frontend/public/viewers/vole.yaml b/frontend/public/viewers/vole.yaml new file mode 100644 index 000000000..e785144c3 --- /dev/null +++ b/frontend/public/viewers/vole.yaml @@ -0,0 +1,41 @@ +viewer: + name: "vole" + version: "1.0.0" + repo: "https://github.com/allen-cell-animated/volume-viewer" + template_url: "https://volumeviewer.allencell.org/viewer?url={DATA_URL}" + +capabilities: + # Enumeration of OME-Zarr versions that can be loaded + ome_zarr_versions: [0.4, 0.5] + + compression_codecs: [] + + # Which additional RFCs are supported? + rfcs_supported: [] + + # Are axis names and units respected? + axes: true + + # Are scaling factors respected on multiscales? + scale: true + + # Are translation factors respected on multiscales (including subpixel offsets for lower scale levels)? + translation: true + + # Does the tool support multiple channels? + channels: true + + # Does the tool support multiple timepoints? + timepoints: true + + # Are labels loaded when available? + labels: false + + # Are HCS plates loaded when available? + hcs_plates: false + + # Does the viewer handle multiple images in a bioformats2raw layout? + bioformats2raw_layout: false + + # Is the OMERO metadata used to e.g. color the channels? + omero_metadata: false From 10bafa7c5f1b6c09f51902d48546db75bdd72773 Mon Sep 17 00:00:00 2001 From: allison-truhlar Date: Mon, 9 Feb 2026 21:39:31 +0000 Subject: [PATCH 032/104] refactor: simplify viewers config to manifest_url entries --- frontend/src/config/viewers.config.yaml | 52 +++-------- frontend/src/config/viewersConfig.ts | 118 ++++++------------------ 2 files changed, 40 insertions(+), 130 deletions(-) diff --git a/frontend/src/config/viewers.config.yaml b/frontend/src/config/viewers.config.yaml index 756eccb8c..d9b25f7bb 100644 --- a/frontend/src/config/viewers.config.yaml +++ b/frontend/src/config/viewers.config.yaml @@ -1,50 +1,20 @@ # Fileglancer OME-Zarr Viewers Configuration # -# This file defines which OME-Zarr viewers are available in your Fileglancer deployment. -# The @bioimagetools/capability-manifest library is used to determine compatibility -# for viewers that have a capability manifest. -# -# Global Configuration: -# - valid_ome_zarr_versions: Array of OME-Zarr versions supported by the application (e.g., `[0.4, 0.5]`) -# -# For viewers with capability manifests, you must provide: -# - name: must match name value in capability manifest -# Optionally: -# - url: to override the template url in the capability manifest -# - logo: to override the default logo at {name}.png -# - label: Custom tooltip text (defaults to "View in {Name}") -# -# For viewers without capability manifests, you must provide: -# - name: Viewer identifier -# - url: URL template (use `{dataLink}` placeholder for dataset URL) -# - ome_zarr_versions: Array of supported OME-Zarr versions (e.g., `[0.4, 0.5]`) -# Optionally: -# - logo: Filename of logo in `frontend/src/assets/` (defaults to `{name}.png` if not specified) -# - label: Custom tooltip text (defaults to "View in {Name}") - -# Valid OME-Zarr versions supported by this application -valid_ome_zarr_versions: [0.4, 0.5] +# Each viewer entry requires: +# - manifest_url: URL to a capability manifest YAML file +# Optional overrides: +# - instance_template_url: Override the viewer's template_url from the manifest +# - logo: Filename of logo in frontend/src/assets/ (defaults to {normalized_name}.png) +# - label: Custom tooltip text (defaults to "View in {Name}") viewers: - # OME-Zarr viewers with capability manifests - - name: neuroglancer + - manifest_url: 'https://raw.githubusercontent.com/JaneliaSciComp/fileglancer/main/frontend/public/viewers/neuroglancer.yaml' - - name: avivator - # Optional: Override the viewer URL from the capability manifest - # In this example, override to use Janelia's custom deployment - url: 'https://janeliascicomp.github.io/viv/' + - manifest_url: 'https://raw.githubusercontent.com/JaneliaSciComp/fileglancer/main/frontend/public/viewers/vizarr.yaml' + instance_template_url: 'https://janeliascicomp.github.io/viv/' - # OME-Zarr viewers without capability manifests - # OME-Zarr Validator - # Logo will automatically resolve to @/assets/validator.png - - name: validator - url: 'https://ome.github.io/ome-ngff-validator/?source={dataLink}' - ome_zarr_versions: [0.4, 0.5] + - manifest_url: 'https://raw.githubusercontent.com/JaneliaSciComp/fileglancer/main/frontend/public/viewers/ome-zarr-validator.yaml' label: 'View in OME-Zarr Validator' - # Vol-E - Allen Cell Explorer 3D viewer - # Logo will automatically resolve to @/assets/vole.png - - name: vole - url: 'https://volumeviewer.allencell.org/viewer?url={dataLink}' - ome_zarr_versions: [0.4, 0.5] + - manifest_url: 'https://raw.githubusercontent.com/JaneliaSciComp/fileglancer/main/frontend/public/viewers/vol-e.yaml' label: 'View in Vol-E' diff --git a/frontend/src/config/viewersConfig.ts b/frontend/src/config/viewersConfig.ts index fccabaafa..4b868609b 100644 --- a/frontend/src/config/viewersConfig.ts +++ b/frontend/src/config/viewersConfig.ts @@ -6,23 +6,22 @@ import { z } from 'zod'; */ const ViewerConfigEntrySchema = z.object( { - name: z.string({ - message: 'Each viewer must have a "name" field (string)' - }), - url: z.string({ message: '"url" must be a string' }).optional(), + manifest_url: z + .string({ + message: 'Each viewer must have a "manifest_url" field (string)' + }) + .url({ message: '"manifest_url" must be a valid URL' }), + instance_template_url: z + .string({ message: '"instance_template_url" must be a string' }) + .optional(), label: z.string({ message: '"label" must be a string' }).optional(), - logo: z.string({ message: '"logo" must be a string' }).optional(), - ome_zarr_versions: z - .array(z.number(), { message: '"ome_zarr_versions" must be an array' }) - .optional() + logo: z.string({ message: '"logo" must be a string' }).optional() }, { error: iss => { - // When the viewer entry itself isn't an object if (iss.code === 'invalid_type' && iss.expected === 'object') { - return 'Each viewer must have a "name" field (string)'; + return 'Each viewer must be an object with a "manifest_url" field'; } - // Return undefined to use default behavior for other errors return undefined; } } @@ -33,30 +32,20 @@ const ViewerConfigEntrySchema = z.object( */ const ViewersConfigYamlSchema = z.object( { - valid_ome_zarr_versions: z - .array( - z.number({ - message: '"valid_ome_zarr_versions" must contain only numbers' - }), - { - message: - 'Configuration must have a "valid_ome_zarr_versions" field containing an array of numbers' - } - ) + viewers: z + .array(ViewerConfigEntrySchema, { + message: + 'Configuration must have a "viewers" field containing an array of viewers' + }) .min(1, { - message: '"valid_ome_zarr_versions" must not be empty' - }), - viewers: z.array(ViewerConfigEntrySchema, { - message: - 'Configuration must have a "viewers" field containing an array of viewers' - }) + message: '"viewers" must contain at least one viewer' + }) }, { error: iss => { if (iss.code === 'invalid_type') { return { - message: - 'Configuration must have "valid_ome_zarr_versions" and "viewers" fields' + message: 'Configuration must have a "viewers" field' }; } } @@ -71,12 +60,8 @@ type ViewersConfigYaml = z.infer; /** * Parse and validate viewers configuration YAML * @param yamlContent - The YAML content to parse - * @param viewersWithManifests - Array of viewer names that have capability manifests (from initializeViewerManifests) */ -export function parseViewersConfig( - yamlContent: string, - viewersWithManifests: string[] = [] -): ViewersConfigYaml { +export function parseViewersConfig(yamlContent: string): ViewersConfigYaml { let parsed: unknown; try { @@ -87,16 +72,13 @@ export function parseViewersConfig( ); } - // First pass: validate basic structure with Zod - const baseValidation = ViewersConfigYamlSchema.safeParse(parsed); + const result = ViewersConfigYamlSchema.safeParse(parsed); - if (!baseValidation.success) { - // Extract the first error message with path context to extract viewer name if possible - const firstError = baseValidation.error.issues[0]; + if (!result.success) { + const firstError = result.error.issues[0]; // Check if the error is nested within a specific viewer if (firstError.path.length > 0 && firstError.path[0] === 'viewers') { - // Extract viewer index from path (e.g., ['viewers', 0, 'ome_zarr_versions']) const viewerIndex = firstError.path[1]; if ( @@ -107,61 +89,19 @@ export function parseViewersConfig( const configData = parsed as { viewers?: unknown[] }; const viewer = configData.viewers?.[viewerIndex]; - // Try to get viewer name if it exists - if (viewer && typeof viewer === 'object' && 'name' in viewer) { - const viewerName = (viewer as { name: unknown }).name; - if (typeof viewerName === 'string') { - throw new Error(`Viewer "${viewerName}": ${firstError.message}`); + if (viewer && typeof viewer === 'object' && 'manifest_url' in viewer) { + const manifestUrl = (viewer as { manifest_url: unknown }).manifest_url; + if (typeof manifestUrl === 'string') { + throw new Error( + `Viewer "${manifestUrl}": ${firstError.message}` + ); } } } } - // Fallback to original error message throw new Error(firstError.message); } - const config = baseValidation.data; - - // Normalize viewer names for comparison (case-insensitive) - const normalizedManifestViewers = viewersWithManifests.map(name => - name.toLowerCase() - ); - - // Second pass: validate manifest-dependent requirements and cross-field constraints - for (let i = 0; i < config.viewers.length; i++) { - const viewer = config.viewers[i]; - - // Check if this viewer has a capability manifest - const hasManifest = normalizedManifestViewers.includes( - viewer.name.toLowerCase() - ); - - // If this viewer doesn't have a capability manifest, require additional fields - if (!hasManifest) { - if (!viewer.url) { - throw new Error( - `Viewer "${viewer.name}" does not have a capability manifest and must specify "url"` - ); - } - if (!viewer.ome_zarr_versions || viewer.ome_zarr_versions.length === 0) { - throw new Error( - `Viewer "${viewer.name}" does not have a capability manifest and must specify "ome_zarr_versions" (array of numbers)` - ); - } - } - - // Validate ome_zarr_versions values if present - if (viewer.ome_zarr_versions) { - for (const version of viewer.ome_zarr_versions) { - if (!config.valid_ome_zarr_versions.includes(version)) { - throw new Error( - `Viewer "${viewer.name}": invalid ome_zarr_version "${version}". Valid versions are: ${config.valid_ome_zarr_versions.join(', ')}` - ); - } - } - } - } - - return config; + return result.data; } From cd05c361075e2c826f13a73ecb99b56d91d43f8b Mon Sep 17 00:00:00 2001 From: allison-truhlar Date: Mon, 9 Feb 2026 21:40:04 +0000 Subject: [PATCH 033/104] refactor: update ViewersContext for new capability-manifest API --- frontend/src/contexts/ViewersContext.tsx | 135 +++++++++-------------- 1 file changed, 55 insertions(+), 80 deletions(-) diff --git a/frontend/src/contexts/ViewersContext.tsx b/frontend/src/contexts/ViewersContext.tsx index 3478010b2..2bbd6118d 100644 --- a/frontend/src/contexts/ViewersContext.tsx +++ b/frontend/src/contexts/ViewersContext.tsx @@ -7,8 +7,8 @@ import { type ReactNode } from 'react'; import { - initializeViewerManifests, - getCompatibleViewers as getCompatibleViewersFromManifest, + loadManifestsFromUrls, + isCompatible, type ViewerManifest, type OmeZarrMetadata } from '@bioimagetools/capability-manifest'; @@ -33,10 +33,8 @@ export interface ValidViewer { logoPath: string; /** Tooltip/alt text label */ label: string; - /** Associated capability manifest (if available) */ - manifest?: ViewerManifest; - /** Supported OME-Zarr versions (for viewers without manifests) */ - supportedVersions?: number[]; + /** Associated capability manifest (required) */ + manifest: ViewerManifest; } interface ViewersContextType { @@ -50,32 +48,30 @@ const ViewersContext = createContext(undefined); /** * Load viewers configuration from build-time config file - * @param viewersWithManifests - Array of viewer names that have capability manifests */ -async function loadViewersConfig( - viewersWithManifests: string[] -): Promise { +async function loadViewersConfig(): Promise { let configYaml: string; try { - // Try to dynamically import the config file - // This will be resolved at build time by Vite const module = await import('@/config/viewers.config.yaml?raw'); configYaml = module.default; log.info( 'Using custom viewers configuration from src/config/viewers.config.yaml' ); - // eslint-disable-next-line @typescript-eslint/no-unused-vars } catch (error) { log.info( - 'No custom viewers.config.yaml found, using default configuration (neuroglancer only)' + 'No custom viewers.config.yaml found, using default configuration' ); - // Return default configuration - return [{ name: 'neuroglancer' }]; + return [ + { + manifest_url: + 'https://raw.githubusercontent.com/JaneliaSciComp/fileglancer/main/frontend/public/viewers/neuroglancer.yaml' + } + ]; } try { - const config = parseViewersConfig(configYaml, viewersWithManifests); + const config = parseViewersConfig(configYaml); return config.viewers; } catch (error) { log.error('Error parsing viewers configuration:', error); @@ -104,79 +100,71 @@ export function ViewersProvider({ try { log.info('Initializing viewers configuration...'); + // Load viewer config entries + const configEntries = await loadViewersConfig(); + log.info(`Loaded configuration for ${configEntries.length} viewers`); + + // Extract manifest URLs + const manifestUrls = configEntries.map(entry => entry.manifest_url); + // Load capability manifests - let loadedManifests: ViewerManifest[] = []; + let manifestsMap: Map; try { - loadedManifests = await initializeViewerManifests(); - log.info( - `Loaded ${loadedManifests.length} viewer capability manifests` - ); + manifestsMap = await loadManifestsFromUrls(manifestUrls); + log.info(`Loaded ${manifestsMap.size} viewer capability manifests`); } catch (manifestError) { - log.warn('Failed to load capability manifests:', manifestError); - log.warn( - 'Continuing without capability manifests. Only custom viewers with explicit configuration will be available.' + log.error('Failed to load capability manifests:', manifestError); + throw new Error( + `Failed to load viewer manifests: ${manifestError instanceof Error ? manifestError.message : 'Unknown error'}` ); } - const viewersWithManifests = loadedManifests.map(m => m.viewer.name); - - // Load viewer config entries - const configEntries = await loadViewersConfig(viewersWithManifests); - log.info(`Loaded configuration for ${configEntries.length} viewers`); - const validated: ValidViewer[] = []; // Map through viewer config entries to validate for (const entry of configEntries) { - const key = normalizeViewerName(entry.name); - const manifest = loadedManifests.find( - m => normalizeViewerName(m.viewer.name) === key - ); + const manifest = manifestsMap.get(entry.manifest_url); - let urlTemplate: string | undefined = entry.url; - let shouldInclude = true; - let skipReason = ''; - - if (manifest) { - if (!urlTemplate) { - // Use manifest template URL if no override - urlTemplate = manifest.viewer.template_url; - } - - if (!urlTemplate) { - shouldInclude = false; - skipReason = `has capability manifest but no template_url and no URL override in config`; - } - } else { - // No capability manifest - if (!urlTemplate) { - shouldInclude = false; - skipReason = `does not have a capability manifest and no URL provided in config`; - } + if (!manifest) { + log.warn( + `Viewer manifest from "${entry.manifest_url}" failed to load, skipping` + ); + continue; } - if (!shouldInclude) { - log.warn(`Viewer "${entry.name}" excluded: ${skipReason}`); + // Determine URL template + const urlTemplate = + entry.instance_template_url ?? manifest.viewer.template_url; + + if (!urlTemplate) { + log.warn( + `Viewer "${manifest.viewer.name}" has no template_url in manifest and no instance_template_url override, skipping` + ); continue; } + // Replace {DATA_URL} with {dataLink} for consistency with existing code + const normalizedUrlTemplate = urlTemplate.replace( + /{DATA_URL}/g, + '{dataLink}' + ); + // Create valid viewer entry - const displayName = - entry.name.charAt(0).toUpperCase() + entry.name.slice(1); + const key = normalizeViewerName(manifest.viewer.name); + const displayName = manifest.viewer.name; const label = entry.label || `View in ${displayName}`; - const logoPath = getViewerLogo(entry.name, entry.logo); + const logoPath = getViewerLogo(manifest.viewer.name, entry.logo); validated.push({ key, displayName, - urlTemplate: urlTemplate!, + urlTemplate: normalizedUrlTemplate, logoPath, label, - manifest, - supportedVersions: entry.ome_zarr_versions + manifest }); - log.info(`Viewer "${entry.name}" registered successfully`); + log.info(`Viewer "${manifest.viewer.name}" registered successfully`); } if (validated.length === 0) { @@ -212,22 +200,9 @@ export function ViewersProvider({ return []; } - return validViewers.filter(viewer => { - if (viewer.manifest) { - const compatibleNames = getCompatibleViewersFromManifest(metadata); - return compatibleNames.includes(viewer.manifest.viewer.name); - } else { - // Manual version check for viewers without manifests - const zarrVersion = metadata.version - ? parseFloat(metadata.version) - : null; - if (zarrVersion === null || !viewer.supportedVersions) { - return false; - } - - return viewer.supportedVersions.includes(zarrVersion); - } - }); + return validViewers.filter(viewer => + isCompatible(viewer.manifest, metadata) + ); }, [validViewers, isInitialized] ); From d892ca13222bf0ddd5fff1bc4951609373c1eab4 Mon Sep 17 00:00:00 2001 From: allison-truhlar Date: Mon, 9 Feb 2026 22:25:59 +0000 Subject: [PATCH 034/104] test: rewrite viewersConfig unit tests for manifest_url API test: refine error message expectations in viewersConfig tests --- .../__tests__/unitTests/viewersConfig.test.ts | 577 +++++------------- 1 file changed, 159 insertions(+), 418 deletions(-) diff --git a/frontend/src/__tests__/unitTests/viewersConfig.test.ts b/frontend/src/__tests__/unitTests/viewersConfig.test.ts index fae881b1a..dc10324d4 100644 --- a/frontend/src/__tests__/unitTests/viewersConfig.test.ts +++ b/frontend/src/__tests__/unitTests/viewersConfig.test.ts @@ -3,610 +3,351 @@ import { parseViewersConfig } from '@/config/viewersConfig'; describe('parseViewersConfig', () => { describe('Valid configurations', () => { - it('should parse valid config with viewers that have manifests', () => { + it('should parse config with single manifest_url viewer', () => { const yaml = ` -valid_ome_zarr_versions: [0.4, 0.5] viewers: - - name: neuroglancer - - name: avivator + - manifest_url: https://example.com/neuroglancer.yaml `; - const result = parseViewersConfig(yaml, ['neuroglancer', 'avivator']); - - expect(result.valid_ome_zarr_versions).toEqual([0.4, 0.5]); - expect(result.viewers).toHaveLength(2); - expect(result.viewers[0].name).toBe('neuroglancer'); - expect(result.viewers[1].name).toBe('avivator'); - }); - - it('should support custom valid_ome_zarr_versions', () => { - const yaml = ` -valid_ome_zarr_versions: [0.4, 0.5, 0.6] -viewers: - - name: custom-viewer - url: https://example.com/{dataLink} - ome_zarr_versions: [0.6] -`; - const result = parseViewersConfig(yaml, []); + const result = parseViewersConfig(yaml); - expect(result.valid_ome_zarr_versions).toEqual([0.4, 0.5, 0.6]); - expect(result.viewers[0].ome_zarr_versions).toEqual([0.6]); + expect(result.viewers).toHaveLength(1); + expect(result.viewers[0].manifest_url).toBe( + 'https://example.com/neuroglancer.yaml' + ); + expect(result.viewers[0].instance_template_url).toBeUndefined(); + expect(result.viewers[0].label).toBeUndefined(); + expect(result.viewers[0].logo).toBeUndefined(); }); - it('should parse config with custom viewer with all required fields', () => { + it('should parse config with multiple viewers', () => { const yaml = ` -valid_ome_zarr_versions: [0.4, 0.5] viewers: - - name: custom-viewer - url: https://example.com/{dataLink} - ome_zarr_versions: [0.4, 0.5] + - manifest_url: https://example.com/neuroglancer.yaml + - manifest_url: https://example.com/avivator.yaml + - manifest_url: https://example.com/validator.yaml `; - const result = parseViewersConfig(yaml, []); + const result = parseViewersConfig(yaml); - expect(result.viewers).toHaveLength(1); - expect(result.viewers[0].name).toBe('custom-viewer'); - expect(result.viewers[0].url).toBe('https://example.com/{dataLink}'); - expect(result.viewers[0].ome_zarr_versions).toEqual([0.4, 0.5]); + expect(result.viewers).toHaveLength(3); + expect(result.viewers[0].manifest_url).toBe( + 'https://example.com/neuroglancer.yaml' + ); + expect(result.viewers[1].manifest_url).toBe( + 'https://example.com/avivator.yaml' + ); + expect(result.viewers[2].manifest_url).toBe( + 'https://example.com/validator.yaml' + ); }); - it('should parse config with optional fields', () => { + it('should parse config with all optional fields', () => { const yaml = ` -valid_ome_zarr_versions: [0.4, 0.5] viewers: - - name: custom-viewer - url: https://example.com/{dataLink} - ome_zarr_versions: [0.4] + - manifest_url: https://example.com/viewer.yaml + instance_template_url: https://example.com/viewer?url={dataLink} label: Custom Viewer Label logo: custom-logo.png `; - const result = parseViewersConfig(yaml, []); + const result = parseViewersConfig(yaml); + expect(result.viewers).toHaveLength(1); + expect(result.viewers[0].manifest_url).toBe( + 'https://example.com/viewer.yaml' + ); + expect(result.viewers[0].instance_template_url).toBe( + 'https://example.com/viewer?url={dataLink}' + ); expect(result.viewers[0].label).toBe('Custom Viewer Label'); expect(result.viewers[0].logo).toBe('custom-logo.png'); }); - it('should allow viewer with manifest to override url', () => { - const yaml = ` -valid_ome_zarr_versions: [0.4, 0.5] -viewers: - - name: neuroglancer - url: https://custom-neuroglancer.com/{dataLink} -`; - const result = parseViewersConfig(yaml, ['neuroglancer']); - - expect(result.viewers[0].url).toBe( - 'https://custom-neuroglancer.com/{dataLink}' - ); - }); - - it('should parse mixed config with manifest and non-manifest viewers', () => { + it('should parse config with manifest_url only (no optional fields)', () => { const yaml = ` -valid_ome_zarr_versions: [0.4, 0.5] viewers: - - name: neuroglancer - - name: custom-viewer - url: https://example.com/{dataLink} - ome_zarr_versions: [0.4] - - name: avivator + - manifest_url: https://example.com/simple-viewer.yaml `; - const result = parseViewersConfig(yaml, ['neuroglancer', 'avivator']); + const result = parseViewersConfig(yaml); - expect(result.viewers).toHaveLength(3); - expect(result.viewers[0].name).toBe('neuroglancer'); - expect(result.viewers[1].name).toBe('custom-viewer'); - expect(result.viewers[2].name).toBe('avivator'); + expect(result.viewers).toHaveLength(1); + expect(result.viewers[0]).toEqual({ + manifest_url: 'https://example.com/simple-viewer.yaml' + }); }); }); describe('Invalid YAML syntax', () => { it('should throw error for malformed YAML', () => { - const invalidYaml = 'viewers:\n - name: test\n invalid: [[['; + const invalidYaml = 'viewers:\n - manifest_url: test\n invalid: [[['; - expect(() => parseViewersConfig(invalidYaml, [])).toThrow( + expect(() => parseViewersConfig(invalidYaml)).toThrow( /Failed to parse viewers configuration YAML/ ); }); - it('should throw error for invalid YAML structure', () => { - const invalidYaml = 'this is not valid yaml [[{]}'; - - // js-yaml parses this as a string, which then fails the object check - expect(() => parseViewersConfig(invalidYaml, [])).toThrow( - /Configuration must have "valid_ome_zarr_versions" and "viewers" fields/ - ); - }); - - it('should throw error for non-object YAML', () => { + it('should throw error for non-object YAML (string)', () => { const invalidYaml = 'just a string'; - expect(() => parseViewersConfig(invalidYaml, [])).toThrow( - /Configuration must have "valid_ome_zarr_versions" and "viewers" fields/ + expect(() => parseViewersConfig(invalidYaml)).toThrow( + /Configuration must have a "viewers" field/ ); }); - it('should throw error for empty YAML', () => { - const invalidYaml = ''; - - expect(() => parseViewersConfig(invalidYaml, [])).toThrow( - /Configuration must have "valid_ome_zarr_versions" and "viewers" fields/ - ); - }); - }); - - describe('Missing required fields', () => { - it('should throw error when valid_ome_zarr_versions is missing', () => { - const yaml = ` -viewers: - - name: neuroglancer -`; + it('should throw error for non-object YAML (number)', () => { + const invalidYaml = '123'; - expect(() => parseViewersConfig(yaml, [])).toThrow( - /Configuration must have a \"valid_ome_zarr_versions\" field containing an array of numbers/ + expect(() => parseViewersConfig(invalidYaml)).toThrow( + /Configuration must have a "viewers" field/ ); }); - it('should throw error when valid_ome_zarr_versions is not an array', () => { - const yaml = ` -valid_ome_zarr_versions: "not-an-array" -viewers: - - name: neuroglancer -`; + it('should throw error for non-object YAML (array)', () => { + const invalidYaml = '[1, 2, 3]'; - expect(() => parseViewersConfig(yaml, [])).toThrow( - /Configuration must have a \"valid_ome_zarr_versions\" field containing an array of numbers/ + expect(() => parseViewersConfig(invalidYaml)).toThrow( + /Configuration must have a "viewers" field/ ); }); - it('should throw error when valid_ome_zarr_versions is empty', () => { - const yaml = ` -valid_ome_zarr_versions: [] -viewers: - - name: neuroglancer -`; + it('should throw error for empty YAML', () => { + const invalidYaml = ''; - expect(() => parseViewersConfig(yaml, [])).toThrow( - /"valid_ome_zarr_versions" must not be empty/ + expect(() => parseViewersConfig(invalidYaml)).toThrow( + /Configuration must have a "viewers" field/ ); }); - it('should throw error when valid_ome_zarr_versions contains non-numbers', () => { - const yaml = ` -valid_ome_zarr_versions: [0.4, "0.5"] -viewers: - - name: neuroglancer -`; + it('should throw error for null YAML', () => { + const invalidYaml = 'null'; - expect(() => parseViewersConfig(yaml, [])).toThrow( - /"valid_ome_zarr_versions" must contain only numbers/ + expect(() => parseViewersConfig(invalidYaml)).toThrow( + /Configuration must have a "viewers" field/ ); }); + }); + describe('Missing required fields', () => { it('should throw error when viewers array is missing', () => { const yaml = ` -valid_ome_zarr_versions: [0.4, 0.5] name: some-config other_field: value `; - expect(() => parseViewersConfig(yaml, [])).toThrow( - /Configuration must have a \"viewers\" field containing an array of viewers/ - ); - }); - - it('should throw error when viewers is not an array', () => { - const yaml = ` -valid_ome_zarr_versions: [0.4, 0.5] -viewers: not-an-array -`; - - expect(() => parseViewersConfig(yaml, [])).toThrow( - /Configuration must have a \"viewers\" field containing an array of viewers/ - ); - }); - - it('should throw error when viewer is not an object', () => { - const yaml = ` -valid_ome_zarr_versions: [0.4, 0.5] -viewers: - - just-a-string -`; - - expect(() => parseViewersConfig(yaml, [])).toThrow( - /Each viewer must have a "name" field \(string\)/ - ); - }); - - it('should throw error when viewer lacks name field', () => { - const yaml = ` -valid_ome_zarr_versions: [0.4, 0.5] -viewers: - - url: https://example.com - ome_zarr_versions: [0.4] -`; - - expect(() => parseViewersConfig(yaml, [])).toThrow( - /Each viewer must have a "name" field \(string\)/ - ); - }); - - it('should throw error when viewer name is not a string', () => { - const yaml = ` -valid_ome_zarr_versions: [0.4, 0.5] -viewers: - - name: 123 - url: https://example.com - ome_zarr_versions: [0.4] -`; - - expect(() => parseViewersConfig(yaml, [])).toThrow( - /Each viewer must have a "name" field \(string\)/ - ); - }); - - it('should throw error when custom viewer (no manifest) lacks url', () => { - const yaml = ` -valid_ome_zarr_versions: [0.4, 0.5] -viewers: - - name: custom-viewer - ome_zarr_versions: [0.4] -`; - - expect(() => parseViewersConfig(yaml, [])).toThrow( - /Viewer "custom-viewer" does not have a capability manifest and must specify "url"/ + expect(() => parseViewersConfig(yaml)).toThrow( + /Configuration must have a "viewers" field containing an array/ ); }); - it('should throw error when custom viewer (no manifest) lacks ome_zarr_versions', () => { + it('should throw error when viewer is missing manifest_url', () => { const yaml = ` -valid_ome_zarr_versions: [0.4, 0.5] viewers: - - name: custom-viewer - url: https://example.com/{dataLink} + - label: Custom Label + logo: custom.png `; - expect(() => parseViewersConfig(yaml, [])).toThrow( - /Viewer "custom-viewer" does not have a capability manifest and must specify "ome_zarr_versions" \(array of numbers\)/ + expect(() => parseViewersConfig(yaml)).toThrow( + /Each viewer must have a "manifest_url" field/ ); }); - it('should throw error when custom viewer has empty ome_zarr_versions array', () => { + it('should throw error when viewers array is empty', () => { const yaml = ` -valid_ome_zarr_versions: [0.4, 0.5] -viewers: - - name: custom-viewer - url: https://example.com/{dataLink} - ome_zarr_versions: [] +viewers: [] `; - expect(() => parseViewersConfig(yaml, [])).toThrow( - /Viewer "custom-viewer" does not have a capability manifest and must specify "ome_zarr_versions" \(array of numbers\)/ + expect(() => parseViewersConfig(yaml)).toThrow( + /"viewers" must contain at least one viewer/ ); }); }); describe('Invalid field types', () => { - it('should throw error when url is not a string (for custom viewer)', () => { + it('should throw error when manifest_url is not a string', () => { const yaml = ` -valid_ome_zarr_versions: [0.4, 0.5] viewers: - - name: custom-viewer - url: 123 - ome_zarr_versions: [0.4] + - manifest_url: 123 `; - // The required field check happens first, so if url is wrong type, - // it's caught by the "must specify url" check - expect(() => parseViewersConfig(yaml, [])).toThrow( - /Viewer "custom-viewer": "url" must be a string/ + expect(() => parseViewersConfig(yaml)).toThrow( + /Each viewer must have a "manifest_url" field/ ); }); - it('should throw error when url override is not a string (for manifest viewer)', () => { + it('should throw error when manifest_url is not a valid URL', () => { const yaml = ` -valid_ome_zarr_versions: [0.4, 0.5] viewers: - - name: neuroglancer - url: 123 + - manifest_url: not-a-valid-url `; - expect(() => parseViewersConfig(yaml, ['neuroglancer'])).toThrow( - /Viewer "neuroglancer": "url" must be a string/ + expect(() => parseViewersConfig(yaml)).toThrow( + /"manifest_url" must be a valid URL/ ); }); it('should throw error when label is not a string', () => { const yaml = ` -valid_ome_zarr_versions: [0.4, 0.5] viewers: - - name: custom-viewer - url: https://example.com - ome_zarr_versions: [0.4] + - manifest_url: https://example.com/viewer.yaml label: 123 `; - expect(() => parseViewersConfig(yaml, [])).toThrow( - /Viewer "custom-viewer": "label" must be a string/ - ); + expect(() => parseViewersConfig(yaml)).toThrow(/"label" must be a string/); }); it('should throw error when logo is not a string', () => { const yaml = ` -valid_ome_zarr_versions: [0.4, 0.5] viewers: - - name: custom-viewer - url: https://example.com - ome_zarr_versions: [0.4] + - manifest_url: https://example.com/viewer.yaml logo: 123 `; - expect(() => parseViewersConfig(yaml, [])).toThrow( - /Viewer "custom-viewer": "logo" must be a string/ - ); - }); - - it('should throw error when ome_zarr_versions is not an array (for custom viewer)', () => { - const yaml = ` -valid_ome_zarr_versions: [0.4, 0.5] -viewers: - - name: custom-viewer - url: https://example.com - ome_zarr_versions: "not-an-array" -`; - - // The required field check happens first and checks if it's an array - expect(() => parseViewersConfig(yaml, [])).toThrow( - /Viewer "custom-viewer": "ome_zarr_versions" must be an array/ - ); - }); - - it('should throw error when ome_zarr_versions override is not an array (for manifest viewer)', () => { - const yaml = ` -valid_ome_zarr_versions: [0.4, 0.5] -viewers: - - name: neuroglancer - ome_zarr_versions: "not-an-array" -`; - - expect(() => parseViewersConfig(yaml, ['neuroglancer'])).toThrow( - /Viewer "neuroglancer": "ome_zarr_versions" must be an array/ - ); - }); - }); - - describe('OME-Zarr version validation', () => { - it('should accept valid ome_zarr_versions (0.4 and 0.5)', () => { - const yaml = ` -valid_ome_zarr_versions: [0.4, 0.5] -viewers: - - name: custom-viewer - url: https://example.com - ome_zarr_versions: [0.4, 0.5] -`; - - const result = parseViewersConfig(yaml, []); - - expect(result.viewers[0].ome_zarr_versions).toEqual([0.4, 0.5]); + expect(() => parseViewersConfig(yaml)).toThrow(/"logo" must be a string/); }); - it('should throw error for invalid ome_zarr_version (0.3)', () => { + it('should throw error when instance_template_url is not a string', () => { const yaml = ` -valid_ome_zarr_versions: [0.4, 0.5] viewers: - - name: custom-viewer - url: https://example.com - ome_zarr_versions: [0.3] + - manifest_url: https://example.com/viewer.yaml + instance_template_url: 123 `; - expect(() => parseViewersConfig(yaml, [])).toThrow( - /invalid ome_zarr_version "0.3". Valid versions are: 0.4, 0.5/ + expect(() => parseViewersConfig(yaml)).toThrow( + /"instance_template_url" must be a string/ ); }); - it('should throw error for invalid ome_zarr_version (1.0)', () => { + it('should throw error when viewer entry is not an object (string in array)', () => { const yaml = ` -valid_ome_zarr_versions: [0.4, 0.5] viewers: - - name: custom-viewer - url: https://example.com - ome_zarr_versions: [1.0] + - just-a-string `; - expect(() => parseViewersConfig(yaml, [])).toThrow( - /invalid ome_zarr_version "1". Valid versions are: 0.4, 0.5/ + expect(() => parseViewersConfig(yaml)).toThrow( + /Each viewer must be an object with a "manifest_url" field/ ); }); - it('should throw error when mixing valid and invalid versions', () => { + it('should throw error when viewer entry is not an object (number in array)', () => { const yaml = ` -valid_ome_zarr_versions: [0.4, 0.5] viewers: - - name: custom-viewer - url: https://example.com - ome_zarr_versions: [0.3, 0.4, 0.5] + - 123 `; - expect(() => parseViewersConfig(yaml, [])).toThrow( - /invalid ome_zarr_version "0.3". Valid versions are: 0.4, 0.5/ + expect(() => parseViewersConfig(yaml)).toThrow( + /Each viewer must be an object with a "manifest_url" field/ ); }); - it('should throw error for invalid version in manifest viewer override', () => { + it('should throw error when viewers is not an array', () => { const yaml = ` -valid_ome_zarr_versions: [0.4, 0.5] -viewers: - - name: neuroglancer - ome_zarr_versions: [0.3] +viewers: not-an-array `; - expect(() => parseViewersConfig(yaml, ['neuroglancer'])).toThrow( - /invalid ome_zarr_version "0.3". Valid versions are: 0.4, 0.5/ + expect(() => parseViewersConfig(yaml)).toThrow( + /Configuration must have a "viewers" field containing an array/ ); }); - - it('should accept only 0.4', () => { - const yaml = ` -valid_ome_zarr_versions: [0.4, 0.5] -viewers: - - name: custom-viewer - url: https://example.com - ome_zarr_versions: [0.4] -`; - - const result = parseViewersConfig(yaml, []); - - expect(result.viewers[0].ome_zarr_versions).toEqual([0.4]); - }); - - it('should accept only 0.5', () => { - const yaml = ` -valid_ome_zarr_versions: [0.4, 0.5] -viewers: - - name: custom-viewer - url: https://example.com - ome_zarr_versions: [0.5] -`; - - const result = parseViewersConfig(yaml, []); - - expect(result.viewers[0].ome_zarr_versions).toEqual([0.5]); - }); - }); - - describe('Case sensitivity and normalization', () => { - it('should handle case-insensitive manifest matching', () => { - const yaml = ` -valid_ome_zarr_versions: [0.4, 0.5] -viewers: - - name: Neuroglancer - - name: AVIVATOR -`; - - // Manifest names are lowercase - const result = parseViewersConfig(yaml, ['neuroglancer', 'avivator']); - - expect(result.viewers).toHaveLength(2); - expect(result.viewers[0].name).toBe('Neuroglancer'); - expect(result.viewers[1].name).toBe('AVIVATOR'); - }); - - it('should match manifests case-insensitively for mixed case', () => { - const yaml = ` -valid_ome_zarr_versions: [0.4, 0.5] -viewers: - - name: NeuroGlancer -`; - - // Should recognize this has a manifest (neuroglancer) - const result = parseViewersConfig(yaml, ['neuroglancer']); - - expect(result.viewers).toHaveLength(1); - expect(result.viewers[0].name).toBe('NeuroGlancer'); - // Should not require url or ome_zarr_versions since it has a manifest - }); }); describe('Edge cases', () => { - it('should handle empty viewers array', () => { - const yaml = ` -valid_ome_zarr_versions: [0.4, 0.5] -viewers: [] -`; - - const result = parseViewersConfig(yaml, []); - - expect(result.viewers).toHaveLength(0); - }); - - it('should handle viewer with only name (has manifest)', () => { + it('should handle single viewer with only manifest_url', () => { const yaml = ` -valid_ome_zarr_versions: [0.4, 0.5] viewers: - - name: neuroglancer + - manifest_url: https://example.com/viewer.yaml `; - const result = parseViewersConfig(yaml, ['neuroglancer']); + const result = parseViewersConfig(yaml); expect(result.viewers).toHaveLength(1); - expect(result.viewers[0]).toEqual({ name: 'neuroglancer' }); + expect(result.viewers[0]).toEqual({ + manifest_url: 'https://example.com/viewer.yaml' + }); }); - it('should preserve all valid fields in parsed output', () => { + it('should preserve all valid optional fields in output', () => { const yaml = ` -valid_ome_zarr_versions: [0.4, 0.5] viewers: - - name: custom - url: https://example.com + - manifest_url: https://example.com/viewer.yaml + instance_template_url: https://example.com/viewer?url={dataLink} label: Custom Label logo: custom.png - ome_zarr_versions: [0.4, 0.5] `; - const result = parseViewersConfig(yaml, []); + const result = parseViewersConfig(yaml); expect(result.viewers[0]).toEqual({ - name: 'custom', - url: 'https://example.com', + manifest_url: 'https://example.com/viewer.yaml', + instance_template_url: 'https://example.com/viewer?url={dataLink}', label: 'Custom Label', - logo: 'custom.png', - ome_zarr_versions: [0.4, 0.5] + logo: 'custom.png' }); }); - it('should handle multiple valid ome_zarr_versions', () => { + it('should strip/ignore unknown fields', () => { const yaml = ` -valid_ome_zarr_versions: [0.4, 0.5] viewers: - - name: custom - url: https://example.com - ome_zarr_versions: [0.4, 0.5] + - manifest_url: https://example.com/viewer.yaml + unknown_field: some-value + another_unknown: 123 `; - const result = parseViewersConfig(yaml, []); + const result = parseViewersConfig(yaml); - expect(result.viewers[0].ome_zarr_versions).toEqual([0.4, 0.5]); + // Zod should strip unknown fields + expect(result.viewers[0]).toEqual({ + manifest_url: 'https://example.com/viewer.yaml' + }); + expect(result.viewers[0]).not.toHaveProperty('unknown_field'); + expect(result.viewers[0]).not.toHaveProperty('another_unknown'); }); - it('should handle single ome_zarr_version in array', () => { + it('should accept http and https URLs', () => { const yaml = ` -valid_ome_zarr_versions: [0.4, 0.5] viewers: - - name: custom - url: https://example.com - ome_zarr_versions: [0.4] + - manifest_url: http://example.com/viewer.yaml + - manifest_url: https://example.com/viewer.yaml `; - const result = parseViewersConfig(yaml, []); + const result = parseViewersConfig(yaml); - expect(result.viewers[0].ome_zarr_versions).toEqual([0.4]); + expect(result.viewers).toHaveLength(2); + expect(result.viewers[0].manifest_url).toBe( + 'http://example.com/viewer.yaml' + ); + expect(result.viewers[1].manifest_url).toBe( + 'https://example.com/viewer.yaml' + ); }); - }); - describe('Default parameter behavior', () => { - it('should use empty array as default for viewersWithManifests', () => { + it('should handle URL with special characters', () => { const yaml = ` -valid_ome_zarr_versions: [0.4, 0.5] viewers: - - name: custom - url: https://example.com - ome_zarr_versions: [0.4] + - manifest_url: https://example.com/viewer-config_v2.yaml?version=1.0&format=yaml `; - // Not passing second parameter const result = parseViewersConfig(yaml); - expect(result.viewers).toHaveLength(1); - expect(result.viewers[0].name).toBe('custom'); + expect(result.viewers[0].manifest_url).toBe( + 'https://example.com/viewer-config_v2.yaml?version=1.0&format=yaml' + ); }); - it('should treat viewer as non-manifest when viewersWithManifests is empty', () => { + it('should handle empty optional strings', () => { const yaml = ` -valid_ome_zarr_versions: [0.4, 0.5] viewers: - - name: neuroglancer + - manifest_url: https://example.com/viewer.yaml + label: "" + logo: "" + instance_template_url: "" `; - // Even though neuroglancer typically has a manifest, - // if not in the list, it should require url and versions - expect(() => parseViewersConfig(yaml, [])).toThrow(/must specify "url"/); + const result = parseViewersConfig(yaml); + + expect(result.viewers[0]).toEqual({ + manifest_url: 'https://example.com/viewer.yaml', + label: '', + logo: '', + instance_template_url: '' + }); }); }); }); From d5261a590131e8896bedd00e816e210d6bb47a5b Mon Sep 17 00:00:00 2001 From: allison-truhlar Date: Mon, 9 Feb 2026 22:25:59 +0000 Subject: [PATCH 035/104] test: rewrite DataToolLinks component tests for manifest_url API test: fix mock hoisting in DataToolLinks test test: simplify capability manifest mock structure test: update tests for new capability-manifest API test fix test fix --- .devcontainer/devcontainer.json | 3 +- frontend/package-lock.json | 28 ++-- frontend/package.json | 2 +- .../componentTests/DataToolLinks.test.tsx | 135 ++++++++++++------ 4 files changed, 113 insertions(+), 55 deletions(-) diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 191fe3b7a..f69ec7235 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -32,7 +32,8 @@ }, "mounts": [ "source=${localEnv:HOME}/.claude,target=/home/vscode/.claude,type=bind", - "source=fileglancer-pixi,target=${containerWorkspaceFolder}/.pixi,type=volume" + "source=fileglancer-pixi,target=${containerWorkspaceFolder}/.pixi,type=volume", + "source=/groups/scicompsoft/home/truhlara/gh-repos/capability-manifest,target=/workspaces/capability-manifest,type=bind" ], "remoteEnv": { "NODE_OPTIONS": "--max-old-space-size=4096", diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 8f9e233f9..38203f283 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -9,7 +9,7 @@ "version": "2.4.0", "license": "BSD-3-Clause", "dependencies": { - "@bioimagetools/capability-manifest": "^0.2.0", + "@bioimagetools/capability-manifest": "file:../../capability-manifest", "@material-tailwind/react": "^3.0.0-beta.24", "@tanstack/react-query": "^5.90.2", "@tanstack/react-table": "^8.21.3", @@ -69,6 +69,23 @@ "vitest": "^3.1.3" } }, + "../../capability-manifest": { + "name": "@bioimagetools/capability-manifest", + "version": "0.3.1", + "license": "ISC", + "dependencies": { + "js-yaml": "^4.1.1" + }, + "devDependencies": { + "@types/js-yaml": "^4.0.9", + "@types/node": "^24.10.1", + "ome-zarr.js": "^0.0.17", + "typescript": "^5.9.3", + "vite": "^7.2.2", + "vitest": "^4.0.18", + "zarrita": "^0.5.4" + } + }, "node_modules/@adobe/css-tools": { "version": "4.4.4", "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.4.tgz", @@ -403,13 +420,8 @@ } }, "node_modules/@bioimagetools/capability-manifest": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/@bioimagetools/capability-manifest/-/capability-manifest-0.2.0.tgz", - "integrity": "sha512-eZa4DmOCxbxS6BN8aHOqPNrfCP/zWMigvVwCY3aNTlLTsG3ZQ2Ap/TJetY7qnagNl3sIXYHiHbKDhBYNee5rDw==", - "license": "ISC", - "dependencies": { - "js-yaml": "^4.1.1" - } + "resolved": "../../capability-manifest", + "link": true }, "node_modules/@emnapi/core": { "version": "1.7.1", diff --git a/frontend/package.json b/frontend/package.json index 48c6c7bef..5afd2c801 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -30,7 +30,7 @@ "test": "vitest" }, "dependencies": { - "@bioimagetools/capability-manifest": "^0.2.0", + "@bioimagetools/capability-manifest": "file:../../capability-manifest", "@material-tailwind/react": "^3.0.0-beta.24", "@tanstack/react-query": "^5.90.2", "@tanstack/react-table": "^8.21.3", diff --git a/frontend/src/__tests__/componentTests/DataToolLinks.test.tsx b/frontend/src/__tests__/componentTests/DataToolLinks.test.tsx index b3d0d8a15..0d27b3645 100644 --- a/frontend/src/__tests__/componentTests/DataToolLinks.test.tsx +++ b/frontend/src/__tests__/componentTests/DataToolLinks.test.tsx @@ -17,30 +17,19 @@ vi.mock('@/logger', () => ({ })); // Mock capability manifest to avoid network requests in tests -vi.mock('@bioimagetools/capability-manifest', () => ({ - initializeViewerManifests: vi.fn(async () => [ - { - viewer: { - name: 'neuroglancer', - template_url: 'https://neuroglancer.com/#{dataLink}' - } - }, - { - viewer: { - name: 'avivator', - template_url: 'https://avivator.com/?url={dataLink}' - } - } - ]), - getCompatibleViewers: vi.fn(() => ['neuroglancer', 'avivator']) +const mockCapabilityManifest = vi.hoisted(() => ({ + loadManifestsFromUrls: vi.fn(), + isCompatible: vi.fn() })); +vi.mock('@bioimagetools/capability-manifest', () => mockCapabilityManifest); + const mockOpenWithToolUrls: OpenWithToolUrls = { copy: 'http://localhost:3000/test/copy/url', validator: 'http://localhost:3000/test/validator/url', neuroglancer: 'http://localhost:3000/test/neuroglancer/url', vole: 'http://localhost:3000/test/vole/url', - avivator: 'http://localhost:3000/test/avivator/url' + vizarr: 'http://localhost:3000/test/vizarr/url' }; // Helper component to wrap DataToolLinks with ViewersProvider @@ -77,6 +66,10 @@ function renderDataToolLinks( describe('DataToolLinks - Error Scenarios', () => { beforeEach(() => { vi.clearAllMocks(); + + // Default mock: return empty Map (no manifests loaded) + mockCapabilityManifest.loadManifestsFromUrls.mockResolvedValue(new Map()); + mockCapabilityManifest.isCompatible.mockReturnValue(false); }); describe('Invalid YAML syntax', () => { @@ -87,9 +80,9 @@ describe('DataToolLinks - Error Scenarios', () => { // Import the parseViewersConfig function to test it directly const { parseViewersConfig } = await import('@/config/viewersConfig'); - const invalidYaml = 'viewers:\n - name: test\n invalid: [[['; + const invalidYaml = 'viewers:\n - manifest_url: test\n invalid: [[['; - expect(() => parseViewersConfig(invalidYaml, [])).toThrow( + expect(() => parseViewersConfig(invalidYaml)).toThrow( /Failed to parse viewers configuration YAML/ ); }); @@ -111,34 +104,29 @@ describe('DataToolLinks - Error Scenarios', () => { }); describe('Missing required fields', () => { - it('should throw error when custom viewer lacks required url field', async () => { + it('should throw error when viewer lacks required manifest_url field', async () => { const { parseViewersConfig } = await import('@/config/viewersConfig'); - const configMissingUrl = ` -valid_ome_zarr_versions: [0.4, 0.5] + const configMissingManifestUrl = ` viewers: - - name: custom-viewer - # Missing url for viewer without manifest + - label: Custom Label + # Missing manifest_url `; - expect(() => parseViewersConfig(configMissingUrl, [])).toThrow( - /does not have a capability manifest and must specify "url"/ + expect(() => parseViewersConfig(configMissingManifestUrl)).toThrow( + /Each viewer must have a "manifest_url" field/ ); }); - it('should throw error when custom viewer lacks ome_zarr_versions', async () => { + it('should throw error when viewers array is empty', async () => { const { parseViewersConfig } = await import('@/config/viewersConfig'); - const configMissingVersions = ` -valid_ome_zarr_versions: [0.4, 0.5] -viewers: - - name: custom-viewer - url: https://example.com - # Missing ome_zarr_versions for viewer without manifest + const configEmptyViewers = ` +viewers: [] `; - expect(() => parseViewersConfig(configMissingVersions, [])).toThrow( - /does not have a capability manifest and must specify "ome_zarr_versions"/ + expect(() => parseViewersConfig(configEmptyViewers)).toThrow( + /"viewers" must contain at least one viewer/ ); }); }); @@ -147,6 +135,34 @@ viewers: describe('DataToolLinks - Edge Cases', () => { beforeEach(() => { vi.clearAllMocks(); + + // Mock loadManifestsFromUrls to return Map with manifests + // URLs must match those in viewers.config.yaml + mockCapabilityManifest.loadManifestsFromUrls.mockResolvedValue( + new Map([ + [ + 'https://raw.githubusercontent.com/JaneliaSciComp/fileglancer/main/frontend/public/viewers/neuroglancer.yaml', + { + viewer: { + name: 'Neuroglancer', + template_url: 'https://neuroglancer.com/#!{DATA_URL}' + } + } + ], + [ + 'https://raw.githubusercontent.com/JaneliaSciComp/fileglancer/main/frontend/public/viewers/vizarr.yaml', + { + viewer: { + name: 'Avivator', + template_url: 'https://vizarr.com/?url={DATA_URL}' + } + } + ] + ]) + ); + + // Mock isCompatible to return true for all viewers + mockCapabilityManifest.isCompatible.mockReturnValue(true); }); describe('Logo rendering in components', () => { @@ -171,16 +187,16 @@ describe('DataToolLinks - Edge Cases', () => { expect(neuroglancerLogo).toBeTruthy(); expect(neuroglancerLogo?.getAttribute('src')).toContain('neuroglancer'); - // Check for avivator logo (known viewer with logo) - const avivatorLogo = images.find( + // Check for avivator logo (name for viewer in vizarr.yaml) + const vizarrLogo = images.find( img => img.getAttribute('alt') === 'Avivator logo' ); - expect(avivatorLogo).toBeTruthy(); - expect(avivatorLogo?.getAttribute('src')).toContain('avivator'); + expect(vizarrLogo).toBeTruthy(); + expect(vizarrLogo?.getAttribute('src')).toContain('avivator'); }); }); - describe('Custom viewer without ome_zarr_versions', () => { + describe('Custom viewer compatibility', () => { it('should exclude viewer URL when set to null in OpenWithToolUrls', async () => { const urls: OpenWithToolUrls = { copy: 'http://localhost:3000/copy', @@ -228,7 +244,36 @@ describe('DataToolLinks - Edge Cases', () => { describe('DataToolLinks - Expected Behavior', () => { beforeEach(() => { vi.clearAllMocks(); + + // Mock loadManifestsFromUrls to return Map with manifests + // URLs must match those in viewers.config.yaml + mockCapabilityManifest.loadManifestsFromUrls.mockResolvedValue( + new Map([ + [ + 'https://raw.githubusercontent.com/JaneliaSciComp/fileglancer/main/frontend/public/viewers/neuroglancer.yaml', + { + viewer: { + name: 'Neuroglancer', + template_url: 'https://neuroglancer.com/#!{DATA_URL}' + } + } + ], + [ + 'https://raw.githubusercontent.com/JaneliaSciComp/fileglancer/main/frontend/public/viewers/vizarr.yaml', + { + viewer: { + name: 'Avivator', + template_url: 'https://vizarr.com/?url={DATA_URL}' + } + } + ] + ]) + ); + + // Mock isCompatible to return true for all viewers + mockCapabilityManifest.isCompatible.mockReturnValue(true); }); + describe('Component behavior with valid viewers', () => { it('should render valid viewer icons and copy icon', async () => { renderDataToolLinks(); @@ -291,19 +336,19 @@ describe('DataToolLinks - Expected Behavior', () => { const images = screen.getAllByRole('img'); - // Should have neuroglancer, avivator, and copy icons at minimum + // Should have neuroglancer, vizarr, and copy icons at minimum expect(images.length).toBeGreaterThanOrEqual(3); // Verify specific logos are present const neuroglancerLogo = images.find( img => img.getAttribute('alt') === 'Neuroglancer logo' ); - const avivatorLogo = images.find( + const vizarrLogo = images.find( img => img.getAttribute('alt') === 'Avivator logo' ); expect(neuroglancerLogo).toBeTruthy(); - expect(avivatorLogo).toBeTruthy(); + expect(vizarrLogo).toBeTruthy(); }); }); @@ -337,8 +382,8 @@ describe('DataToolLinks - Expected Behavior', () => { const neuroglancerButton = screen.getByLabelText('View in Neuroglancer'); expect(neuroglancerButton).toBeInTheDocument(); - const avivatorButton = screen.getByLabelText('View in Avivator'); - expect(avivatorButton).toBeInTheDocument(); + const vizarrButton = screen.getByLabelText('View in Avivator'); + expect(vizarrButton).toBeInTheDocument(); }); }); }); From 05957ba243f0262ae2f4c9b1b469f9efc2fcd0a6 Mon Sep 17 00:00:00 2001 From: allison-truhlar Date: Mon, 9 Feb 2026 21:48:18 +0000 Subject: [PATCH 036/104] docs: update viewer configuration docs for manifest-based system docs fix --- CLAUDE.md | 8 +- docs/ViewersConfiguration.md | 267 +++++++++++++++++++++-------------- 2 files changed, 169 insertions(+), 106 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 8f1f444db..ecfdf6fd6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -171,14 +171,16 @@ Key settings: ## Viewers Configuration -Fileglancer supports dynamic viewer configuration through `viewers.config.yaml`. +Fileglancer uses a manifest-based viewer configuration system. Each viewer is defined by a **capability manifest** (a YAML file describing the viewer's name, URL template, and capabilities). The config file lists manifest URLs and optional overrides. -- **Configuration file**: `frontend/src/config/viewers.config.yaml` +- **Configuration file**: `frontend/src/config/viewers.config.yaml` -- lists viewers by `manifest_url` with optional `instance_template_url`, `label`, and `logo` overrides +- **Manifest files**: `frontend/public/viewers/*.yaml` -- capability manifest YAML files defining each viewer's identity and supported features +- **Compatibility**: Handled by the `@bioimagetools/capability-manifest` library, which checks dataset metadata against manifest capabilities at runtime - **Documentation**: See `docs/ViewersConfiguration.md` To customize viewers: -1. Edit `frontend/src/config/viewers.config.yaml` +1. Edit `frontend/src/config/viewers.config.yaml` (add/remove `manifest_url` entries, override URLs or labels) 2. Rebuild application: `pixi run node-build` The default configuration includes Neuroglancer, Avivator, OME-Zarr Validator, and Vol-E viewers. The config file is bundled at build time. diff --git a/docs/ViewersConfiguration.md b/docs/ViewersConfiguration.md index ae0d56e21..d31111ef0 100644 --- a/docs/ViewersConfiguration.md +++ b/docs/ViewersConfiguration.md @@ -1,190 +1,251 @@ # Viewers Configuration Guide -Fileglancer supports dynamic configuration of OME-Zarr viewers. This allows administrators to customize which viewers are available in their deployment and configure custom viewer URLs. +Fileglancer supports dynamic configuration of OME-Zarr viewers. This allows administrators to customize which viewers are available in their deployment, override viewer URLs, and control how compatibility is determined. ## Overview -The viewer system uses: +The viewer system is built on capability manifests: -- **viewers.config.yaml**: User configuration file defining available viewers -- **@bioimagetools/capability-manifest**: Library for automatic compatibility detection -- **ViewersContext**: React context providing viewer information to the application +- **`viewers.config.yaml`**: Configuration file listing viewers and their manifest URLs +- **Capability manifest files**: YAML files describing each viewer's name, URL template, and capabilities +- **`@bioimagetools/capability-manifest`**: Library that loads manifests and checks dataset compatibility +- **`ViewersContext`**: React context that provides viewer information to the application + +Each viewer is defined by a **capability manifest** hosted at a URL. The configuration file simply lists manifest URLs and optional overrides. At runtime, the manifests are fetched, and the `@bioimagetools/capability-manifest` library determines which viewers are compatible with a given dataset based on the manifest's declared capabilities. ## Quick Start -1. Edit the configuration file at `frontend/src/config/viewers.config.yaml` to enable/disable viewers or customize URLs +1. Edit the configuration file at `frontend/src/config/viewers.config.yaml` +2. Rebuild the application: `pixi run node-build` -2. Build the application - configuration is bundled at build time +## Configuration File -## Configuration File Location +### Location -The configuration file is located at `frontend/src/config/viewers.config.yaml`. +`frontend/src/config/viewers.config.yaml` **Important:** This file is bundled at build time. Changes require rebuilding the application. -The default configuration includes Neuroglancer, Avivator, OME-Zarr Validator, and Vol-E viewers. You can modify this file to add, remove, or customize viewers for your deployment. +### Structure -## Configuration Structure +The configuration file has a single top-level key, `viewers`, containing a list of viewer entries. Each entry requires a `manifest_url` and supports optional overrides. -### Global Configuration +#### Viewer Entry Fields -At the top level of the YAML file, you must specify: +| Field | Required | Description | +| ----------------------- | -------- | -------------------------------------------------------------------------------- | +| `manifest_url` | Yes | URL to a capability manifest YAML file | +| `instance_template_url` | No | Override the viewer's `template_url` from the manifest | +| `label` | No | Custom tooltip text (defaults to "View in {Name}") | +| `logo` | No | Filename of logo in `frontend/src/assets/` (defaults to `{normalized_name}.png`) | -- `valid_ome_zarr_versions`: Array of OME-Zarr versions supported by the application (e.g., `[0.4, 0.5]`) - - **Required field** - must be present and cannot be empty - - This defines which OME-Zarr versions are valid across all viewers - - Individual viewer `ome_zarr_versions` will be validated against this list - - **Default value**: `[0.4, 0.5]` (set in the default config file) +### Default Configuration -### Example: +The default `viewers.config.yaml` configures four viewers: ```yaml -# Valid OME-Zarr versions supported by this application -valid_ome_zarr_versions: [0.4, 0.5] - viewers: - - name: neuroglancer - # ... more viewers + - manifest_url: "https://raw.githubusercontent.com/JaneliaSciComp/fileglancer/main/frontend/public/viewers/neuroglancer.yaml" + + - manifest_url: "https://raw.githubusercontent.com/JaneliaSciComp/fileglancer/main/frontend/public/viewers/vizarr.yaml" + instance_template_url: "https://janeliascicomp.github.io/viv/" + + - manifest_url: "https://raw.githubusercontent.com/JaneliaSciComp/fileglancer/main/frontend/public/viewers/validator.yaml" + label: "View in OME-Zarr Validator" + + - manifest_url: "https://raw.githubusercontent.com/JaneliaSciComp/fileglancer/main/frontend/public/viewers/vole.yaml" + label: "View in Vol-E" ``` -## Viewer Types +## Capability Manifest Files + +Manifest files describe a viewer's identity and capabilities. The default manifests are stored in `frontend/public/viewers/` and are hosted via GitHub. You can host your own manifest files anywhere accessible via URL. -### Viewers with Capability Manifests (Recommended) +### Manifest Structure -These viewers have metadata describing their capabilities, allowing automatic compatibility detection. For example, Neuroglancer and Avivator. For these viewers, you only need to specify the name. URL and compatibility are handled automatically. +A manifest has two sections: `viewer` (identity) and `capabilities` (what the viewer supports). -### Custom Viewers +#### Example: `neuroglancer.yaml` -For viewers without capability manifests, you must provide: +```yaml +viewer: + name: "Neuroglancer" + version: "2.41.2" + repo: "https://github.com/google/neuroglancer" + template_url: https://neuroglancer-demo.appspot.com/#!{"layers":[{"name":"image","source":"{DATA_URL}","type":"image"}]} + +capabilities: + ome_zarr_versions: [0.4, 0.5] + compression_codecs: ["blosc", "zstd", "zlib", "lz4", "gzip"] + rfcs_supported: [] + axes: true + scale: true + translation: true + channels: true + timepoints: true + labels: false + hcs_plates: false + bioformats2raw_layout: false + omero_metadata: false +``` -- `name`: Viewer identifier -- `url`: URL template (use `{dataLink}` placeholder for dataset URL) -- `ome_zarr_versions`: Array of supported OME-Zarr versions (e.g., `[0.4, 0.5]`) - - These values must be in the `valid_ome_zarr_versions` list +### Viewer Section -Optionally: +| Field | Description | +| -------------- | -------------------------------------------------------------- | +| `name` | Display name for the viewer | +| `version` | Viewer version | +| `repo` | Repository URL | +| `template_url` | URL template with `{DATA_URL}` placeholder for the dataset URL | -- `logo`: Filename of logo in `frontend/src/assets/` (defaults to `{name}.png` if not specified) -- `label`: Custom tooltip text (defaults to "View in {Name}") +### Capabilities Section -## Configuration Examples +| Field | Type | Description | +| ----------------------- | -------- | ------------------------------------------------------------ | +| `ome_zarr_versions` | number[] | Supported OME-Zarr specification versions | +| `compression_codecs` | string[] | Supported compression codecs (e.g., "blosc", "zstd", "gzip") | +| `rfcs_supported` | string[] | Additional RFCs supported | +| `axes` | boolean | Whether axis names and units are respected | +| `scale` | boolean | Whether scaling factors on multiscales are respected | +| `translation` | boolean | Whether translation factors on multiscales are respected | +| `channels` | boolean | Whether multiple channels are supported | +| `timepoints` | boolean | Whether multiple timepoints are supported | +| `labels` | boolean | Whether labels are loaded when available | +| `hcs_plates` | boolean | Whether HCS plates are loaded when available | +| `bioformats2raw_layout` | boolean | Whether bioformats2raw layout is handled | +| `omero_metadata` | boolean | Whether OMERO metadata is used (e.g., channel colors) | -### Enable default viewers +## URL Templates and `{DATA_URL}` Placeholder -```yaml -valid_ome_zarr_versions: [0.4, 0.5] +The `{DATA_URL}` placeholder in a manifest's `template_url` (or a config entry's `instance_template_url`) is replaced at runtime with the actual dataset URL. Internally, `{DATA_URL}` is normalized to `{dataLink}` for consistency with the rest of the application. + +For example, given this manifest `template_url`: -viewers: - - name: neuroglancer - - name: avivator +``` +https://neuroglancer-demo.appspot.com/#!{"layers":[{"name":"image","source":"{DATA_URL}","type":"image"}]} ``` -### Override viewer URL +When a user clicks the viewer link for a dataset at `https://example.com/data.zarr`, the final URL becomes: -```yaml -valid_ome_zarr_versions: [0.4, 0.5] +``` +https://neuroglancer-demo.appspot.com/#!{"layers":[{"name":"image","source":"https://example.com/data.zarr","type":"image"}]} +``` + +## Configuration Examples +### Minimal: single viewer + +```yaml viewers: - - name: avivator - url: "https://my-avivator-instance.example.com/?image_url={dataLink}" + - manifest_url: "https://raw.githubusercontent.com/JaneliaSciComp/fileglancer/main/frontend/public/viewers/neuroglancer.yaml" ``` -### Add custom viewer (with convention-based logo) +### Override a viewer's URL -```yaml -valid_ome_zarr_versions: [0.4, 0.5] +Use `instance_template_url` to point to a custom deployment of a viewer while still using its manifest for capability matching: +```yaml viewers: - - name: my-viewer - url: "https://viewer.example.com/?data={dataLink}" - ome_zarr_versions: [0.4, 0.5] - # Logo will automatically resolve to @/assets/my-viewer.png - label: "Open in My Viewer" + - manifest_url: "https://raw.githubusercontent.com/JaneliaSciComp/fileglancer/main/frontend/public/viewers/vizarr.yaml" + instance_template_url: "https://my-avivator-instance.example.com/?image_url={dataLink}" + logo: avivator.png ``` -### Add custom viewer (with explicit logo) +### Add a custom viewer + +To add a new viewer, create a capability manifest YAML file, host it at a URL, and reference it in the config: + +1. Create a manifest file (e.g., `my-viewer.yaml`): ```yaml -valid_ome_zarr_versions: [0.4, 0.5] +viewer: + name: "My Viewer" + version: "1.0.0" + repo: "https://github.com/example/my-viewer" + template_url: "https://viewer.example.com/?data={DATA_URL}" + +capabilities: + ome_zarr_versions: [0.4, 0.5] + compression_codecs: ["blosc", "gzip"] + rfcs_supported: [] + axes: true + scale: true + translation: true + channels: true + timepoints: false + labels: false + hcs_plates: false + bioformats2raw_layout: false + omero_metadata: false +``` + +2. Host the manifest at an accessible URL (e.g., in your own `frontend/public/viewers/` directory, on GitHub, or any web server). +3. Reference it in `viewers.config.yaml`: + +```yaml viewers: - - name: my-viewer - url: "https://viewer.example.com/?data={dataLink}" - ome_zarr_versions: [0.4, 0.5] - logo: "custom-logo.png" # Use @/assets/custom-logo.png + - manifest_url: "https://example.com/manifests/my-viewer.yaml" label: "Open in My Viewer" ``` -### Supporting additional OME-Zarr versions +4. Optionally, add a logo file at `frontend/src/assets/myviewer.png` (the normalized name, lowercase with non-alphanumeric characters removed). + +## How Compatibility Works -If you want to support additional OME-Zarr versions beyond 0.4 and 0.5: +The `@bioimagetools/capability-manifest` library handles all compatibility checking. When a user views an OME-Zarr dataset: -```yaml -valid_ome_zarr_versions: [0.4, 0.5, 0.6] +1. The application reads the dataset's metadata (OME-Zarr version, axes, codecs, etc.) +2. For each registered viewer, the library's `isCompatible()` function compares the dataset metadata against the manifest's declared capabilities +3. Only viewers whose capabilities match the dataset are shown to the user -viewers: - - name: my-viewer - url: "https://viewer.example.com/?data={dataLink}" - ome_zarr_versions: [0.5, 0.6] # Only supports newer versions -``` +This replaces the previous system where `valid_ome_zarr_versions` was a global config setting and custom viewers used simple version matching. Now all compatibility logic is driven by the detailed capabilities declared in each viewer's manifest. ## Adding Custom Viewer Logos Logo resolution follows this order: -1. **Custom logo specified**: If you provide a `logo` field in the config, it will be used -2. **Convention-based**: If no logo is specified, the system looks for `@/assets/{name}.png` -3. **Fallback**: If neither exists, uses `@/assets/fallback_logo.png` +1. **Custom logo specified**: If you provide a `logo` field in the config entry, that filename is looked up in `frontend/src/assets/` +2. **Convention-based**: If no `logo` is specified, the system looks for `frontend/src/assets/{normalized_name}.png`, where the normalized name is the viewer's name lowercased with non-alphanumeric characters removed +3. **Fallback**: If neither is found, `frontend/src/assets/fallback_logo.png` is used -### Examples: +### Examples **Using the naming convention (recommended):** ```yaml -valid_ome_zarr_versions: [0.4, 0.5] - viewers: - - name: my-viewer - # Logo will automatically resolve to @/assets/my-viewer.png + - manifest_url: "https://example.com/manifests/neuroglancer.yaml" + # Logo automatically resolves to @/assets/neuroglancer.png ``` -Just add `frontend/src/assets/my-viewer.png` - no config needed! +Just add `frontend/src/assets/neuroglancer.png` -- no config needed. **Using a custom logo filename:** ```yaml -valid_ome_zarr_versions: [0.4, 0.5] - viewers: - - name: my-viewer - logo: "custom-logo.png" # Will use @/assets/custom-logo.png + - manifest_url: "https://example.com/manifests/vizarr.yaml" + logo: "avivator.png" # Uses @/assets/avivator.png ``` -## How Compatibility Works - -### For Viewers with Manifests - -The @bioimagetools/capability-manifest library checks: - -- OME-Zarr version support -- Axis types and configurations -- Compression codecs -- Special features (labels, HCS plates, etc.) - -### For Custom Viewers - -Simple version matching: - -- Dataset version is compared against `ome_zarr_versions` list -- Viewer is shown only if version matches - ## Development When developing with custom configurations: 1. Edit `frontend/src/config/viewers.config.yaml` 2. Rebuild frontend: `pixi run node-build` or use watch mode: `pixi run dev-watch` -3. Check console for initialization messages +3. Check the browser console for viewer initialization messages + +### Validation + +The configuration is validated at build time using Zod schemas (see `frontend/src/config/viewersConfig.ts`). Validation enforces: + +- The `viewers` array must contain at least one entry +- Each entry must have a valid `manifest_url` (a properly formed URL) +- Optional fields (`instance_template_url`, `label`, `logo`) must be strings if present + +At runtime, manifests that fail to load are skipped with a warning. If a viewer has no `template_url` (neither from its manifest nor from `instance_template_url` in the config), it is also skipped. ## Copy URL Tool From 0fce05c62925197cb9c794055b4df3d15aa8de9f Mon Sep 17 00:00:00 2001 From: allison-truhlar Date: Mon, 9 Feb 2026 17:32:48 -0500 Subject: [PATCH 037/104] chore: prettier formatting --- frontend/src/__tests__/unitTests/viewersConfig.test.ts | 4 +++- frontend/src/config/viewersConfig.ts | 7 +++---- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/frontend/src/__tests__/unitTests/viewersConfig.test.ts b/frontend/src/__tests__/unitTests/viewersConfig.test.ts index dc10324d4..1b4c4f9e9 100644 --- a/frontend/src/__tests__/unitTests/viewersConfig.test.ts +++ b/frontend/src/__tests__/unitTests/viewersConfig.test.ts @@ -190,7 +190,9 @@ viewers: label: 123 `; - expect(() => parseViewersConfig(yaml)).toThrow(/"label" must be a string/); + expect(() => parseViewersConfig(yaml)).toThrow( + /"label" must be a string/ + ); }); it('should throw error when logo is not a string', () => { diff --git a/frontend/src/config/viewersConfig.ts b/frontend/src/config/viewersConfig.ts index 4b868609b..ce0b0b126 100644 --- a/frontend/src/config/viewersConfig.ts +++ b/frontend/src/config/viewersConfig.ts @@ -90,11 +90,10 @@ export function parseViewersConfig(yamlContent: string): ViewersConfigYaml { const viewer = configData.viewers?.[viewerIndex]; if (viewer && typeof viewer === 'object' && 'manifest_url' in viewer) { - const manifestUrl = (viewer as { manifest_url: unknown }).manifest_url; + const manifestUrl = (viewer as { manifest_url: unknown }) + .manifest_url; if (typeof manifestUrl === 'string') { - throw new Error( - `Viewer "${manifestUrl}": ${firstError.message}` - ); + throw new Error(`Viewer "${manifestUrl}": ${firstError.message}`); } } } From 4ab4fe65e4ece774dc9b53bea809d0345b6d00aa Mon Sep 17 00:00:00 2001 From: allison-truhlar Date: Mon, 9 Feb 2026 18:00:02 -0500 Subject: [PATCH 038/104] chore: bump capability-manifest version --- frontend/package-lock.json | 2 +- frontend/package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 38203f283..1bd326fa9 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -9,7 +9,7 @@ "version": "2.4.0", "license": "BSD-3-Clause", "dependencies": { - "@bioimagetools/capability-manifest": "file:../../capability-manifest", + "@bioimagetools/capability-manifest": "^0.3.1", "@material-tailwind/react": "^3.0.0-beta.24", "@tanstack/react-query": "^5.90.2", "@tanstack/react-table": "^8.21.3", diff --git a/frontend/package.json b/frontend/package.json index 5afd2c801..0dc704e73 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -30,7 +30,7 @@ "test": "vitest" }, "dependencies": { - "@bioimagetools/capability-manifest": "file:../../capability-manifest", + "@bioimagetools/capability-manifest": "^0.3.1", "@material-tailwind/react": "^3.0.0-beta.24", "@tanstack/react-query": "^5.90.2", "@tanstack/react-table": "^8.21.3", From d7ea96851f713904dee059eec26e169af308ac42 Mon Sep 17 00:00:00 2001 From: allison-truhlar Date: Thu, 12 Feb 2026 09:30:19 -0500 Subject: [PATCH 039/104] fix: update capability-manifest in package-lock.json --- frontend/package-lock.json | 26 +++++++------------------- 1 file changed, 7 insertions(+), 19 deletions(-) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 1bd326fa9..ffcac8e22 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -69,23 +69,6 @@ "vitest": "^3.1.3" } }, - "../../capability-manifest": { - "name": "@bioimagetools/capability-manifest", - "version": "0.3.1", - "license": "ISC", - "dependencies": { - "js-yaml": "^4.1.1" - }, - "devDependencies": { - "@types/js-yaml": "^4.0.9", - "@types/node": "^24.10.1", - "ome-zarr.js": "^0.0.17", - "typescript": "^5.9.3", - "vite": "^7.2.2", - "vitest": "^4.0.18", - "zarrita": "^0.5.4" - } - }, "node_modules/@adobe/css-tools": { "version": "4.4.4", "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.4.tgz", @@ -420,8 +403,13 @@ } }, "node_modules/@bioimagetools/capability-manifest": { - "resolved": "../../capability-manifest", - "link": true + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/@bioimagetools/capability-manifest/-/capability-manifest-0.3.1.tgz", + "integrity": "sha512-ZSztIhNCETvdUXlERQ7Tqu3lQuOWmxExax4O4fq+J4IO1ze/JoVakKR+uWdaE6+IVpgd/i0U+fGbP5+m45likw==", + "license": "ISC", + "dependencies": { + "js-yaml": "^4.1.1" + } }, "node_modules/@emnapi/core": { "version": "1.7.1", From c1048035d1f018e5799ca22b6fce06cb9d179937 Mon Sep 17 00:00:00 2001 From: allison-truhlar Date: Thu, 12 Feb 2026 09:39:59 -0500 Subject: [PATCH 040/104] tests: fix vole to vol-e in expected text --- frontend/ui-tests/tests/load-zarr-files.spec.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/frontend/ui-tests/tests/load-zarr-files.spec.ts b/frontend/ui-tests/tests/load-zarr-files.spec.ts index a3ca9bd99..6105e7e09 100644 --- a/frontend/ui-tests/tests/load-zarr-files.spec.ts +++ b/frontend/ui-tests/tests/load-zarr-files.spec.ts @@ -35,7 +35,7 @@ test.describe('Zarr File Type Representation', () => { await expect( page.getByRole('img', { name: /neuroglancer logo/i }) ).toBeVisible({ timeout: 10000 }); - await expect(page.getByRole('img', { name: /vole logo/i })).toHaveCount(0); + await expect(page.getByRole('img', { name: /vol-e logo/i })).toHaveCount(0); }); test('Zarr V3 OME-Zarr should show all viewers except avivator', async ({ @@ -54,7 +54,7 @@ test.describe('Zarr File Type Representation', () => { await expect( page.getByRole('img', { name: /neuroglancer logo/i }) ).toBeVisible({ timeout: 10000 }); - await expect(page.getByRole('img', { name: /vole logo/i })).toBeVisible(); + await expect(page.getByRole('img', { name: /vol-e logo/i })).toBeVisible(); await expect( page.getByRole('img', { name: /validator logo/i }) ).toBeVisible(); @@ -79,7 +79,7 @@ test.describe('Zarr File Type Representation', () => { await expect( page.getByRole('img', { name: /neuroglancer logo/i }) ).toBeVisible({ timeout: 10000 }); - await expect(page.getByRole('img', { name: /vole logo/i })).toHaveCount(0); + await expect(page.getByRole('img', { name: /vol-e logo/i })).toHaveCount(0); }); test('Zarr V2 OME-Zarr should display all viewers including avivator', async ({ @@ -98,7 +98,7 @@ test.describe('Zarr File Type Representation', () => { await expect( page.getByRole('img', { name: /neuroglancer logo/i }) ).toBeVisible({ timeout: 10000 }); - await expect(page.getByRole('img', { name: /vole logo/i })).toBeVisible(); + await expect(page.getByRole('img', { name: /vol-e logo/i })).toBeVisible(); await expect( page.getByRole('img', { name: /validator logo/i }) ).toBeVisible(); From 0bd173100fc8618410da7b19bad760b77ab750b2 Mon Sep 17 00:00:00 2001 From: allison-truhlar Date: Thu, 12 Feb 2026 09:52:03 -0500 Subject: [PATCH 041/104] test: remove timeout to rely on longer global timeout value --- frontend/ui-tests/tests/data-link-operations.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/ui-tests/tests/data-link-operations.spec.ts b/frontend/ui-tests/tests/data-link-operations.spec.ts index e81bd560a..714dda3e0 100644 --- a/frontend/ui-tests/tests/data-link-operations.spec.ts +++ b/frontend/ui-tests/tests/data-link-operations.spec.ts @@ -42,7 +42,7 @@ test.describe('Data Link Operations', () => { await expect(page.getByText('zarr.json')).toBeVisible({ timeout: 10000 }); await expect( page.getByRole('link', { name: 'Neuroglancer logo' }) - ).toBeVisible({ timeout: 10000 }); + ).toBeVisible(); const dataLinkToggle = page.getByRole('checkbox', { name: /data link/i }); const confirmButton = page.getByRole('button', { From 2f86cabf4d02e191ed50df1742354b4947ba0ae6 Mon Sep 17 00:00:00 2001 From: allison-truhlar Date: Thu, 12 Feb 2026 11:13:59 -0500 Subject: [PATCH 042/104] chore: bump capability-manifest version --- frontend/package-lock.json | 8 ++++---- frontend/package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index ffcac8e22..0364c8ab5 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -9,7 +9,7 @@ "version": "2.4.0", "license": "BSD-3-Clause", "dependencies": { - "@bioimagetools/capability-manifest": "^0.3.1", + "@bioimagetools/capability-manifest": "^0.3.3", "@material-tailwind/react": "^3.0.0-beta.24", "@tanstack/react-query": "^5.90.2", "@tanstack/react-table": "^8.21.3", @@ -403,9 +403,9 @@ } }, "node_modules/@bioimagetools/capability-manifest": { - "version": "0.3.1", - "resolved": "https://registry.npmjs.org/@bioimagetools/capability-manifest/-/capability-manifest-0.3.1.tgz", - "integrity": "sha512-ZSztIhNCETvdUXlERQ7Tqu3lQuOWmxExax4O4fq+J4IO1ze/JoVakKR+uWdaE6+IVpgd/i0U+fGbP5+m45likw==", + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/@bioimagetools/capability-manifest/-/capability-manifest-0.3.3.tgz", + "integrity": "sha512-McaIsgGyrxRxdQbDmek8On7PeSFA47pYOrfSudvd0d+VtZXX0VCYzq4RmJswVT+h19Bi4b4vTIinhJE0ACsCwA==", "license": "ISC", "dependencies": { "js-yaml": "^4.1.1" diff --git a/frontend/package.json b/frontend/package.json index 0dc704e73..f7769b4ad 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -30,7 +30,7 @@ "test": "vitest" }, "dependencies": { - "@bioimagetools/capability-manifest": "^0.3.1", + "@bioimagetools/capability-manifest": "^0.3.3", "@material-tailwind/react": "^3.0.0-beta.24", "@tanstack/react-query": "^5.90.2", "@tanstack/react-table": "^8.21.3", From 9c629d284f27729a3932ff22dfb92088e0db6497 Mon Sep 17 00:00:00 2001 From: allison-truhlar Date: Thu, 12 Feb 2026 11:39:41 -0500 Subject: [PATCH 043/104] fix: use relative paths for viewer manifest URLs to fix CI failures --- .../__tests__/unitTests/viewersConfig.test.ts | 20 +++++++++++++++++-- frontend/src/config/viewers.config.yaml | 12 ++++++----- frontend/src/config/viewersConfig.ts | 5 ++++- 3 files changed, 29 insertions(+), 8 deletions(-) diff --git a/frontend/src/__tests__/unitTests/viewersConfig.test.ts b/frontend/src/__tests__/unitTests/viewersConfig.test.ts index 1b4c4f9e9..45db020ab 100644 --- a/frontend/src/__tests__/unitTests/viewersConfig.test.ts +++ b/frontend/src/__tests__/unitTests/viewersConfig.test.ts @@ -172,14 +172,14 @@ viewers: ); }); - it('should throw error when manifest_url is not a valid URL', () => { + it('should throw error when manifest_url is not a valid URL or absolute path', () => { const yaml = ` viewers: - manifest_url: not-a-valid-url `; expect(() => parseViewersConfig(yaml)).toThrow( - /"manifest_url" must be a valid URL/ + /"manifest_url" must be a valid URL or an absolute path starting with \// ); }); @@ -320,6 +320,22 @@ viewers: ); }); + it('should accept absolute paths starting with /', () => { + const yaml = ` +viewers: + - manifest_url: /viewers/neuroglancer.yaml + - manifest_url: /viewers/vizarr.yaml +`; + + const result = parseViewersConfig(yaml); + + expect(result.viewers).toHaveLength(2); + expect(result.viewers[0].manifest_url).toBe( + '/viewers/neuroglancer.yaml' + ); + expect(result.viewers[1].manifest_url).toBe('/viewers/vizarr.yaml'); + }); + it('should handle URL with special characters', () => { const yaml = ` viewers: diff --git a/frontend/src/config/viewers.config.yaml b/frontend/src/config/viewers.config.yaml index d9b25f7bb..008f3c6ab 100644 --- a/frontend/src/config/viewers.config.yaml +++ b/frontend/src/config/viewers.config.yaml @@ -1,20 +1,22 @@ # Fileglancer OME-Zarr Viewers Configuration # # Each viewer entry requires: -# - manifest_url: URL to a capability manifest YAML file +# - manifest_url: URL or absolute path to a capability manifest YAML file +# Use absolute paths (e.g. /viewers/neuroglancer.yaml) for manifests bundled +# in the public/ directory, or full URLs for externally hosted manifests. # Optional overrides: # - instance_template_url: Override the viewer's template_url from the manifest # - logo: Filename of logo in frontend/src/assets/ (defaults to {normalized_name}.png) # - label: Custom tooltip text (defaults to "View in {Name}") viewers: - - manifest_url: 'https://raw.githubusercontent.com/JaneliaSciComp/fileglancer/main/frontend/public/viewers/neuroglancer.yaml' + - manifest_url: '/viewers/neuroglancer.yaml' - - manifest_url: 'https://raw.githubusercontent.com/JaneliaSciComp/fileglancer/main/frontend/public/viewers/vizarr.yaml' + - manifest_url: '/viewers/vizarr.yaml' instance_template_url: 'https://janeliascicomp.github.io/viv/' - - manifest_url: 'https://raw.githubusercontent.com/JaneliaSciComp/fileglancer/main/frontend/public/viewers/ome-zarr-validator.yaml' + - manifest_url: '/viewers/validator.yaml' label: 'View in OME-Zarr Validator' - - manifest_url: 'https://raw.githubusercontent.com/JaneliaSciComp/fileglancer/main/frontend/public/viewers/vol-e.yaml' + - manifest_url: '/viewers/vole.yaml' label: 'View in Vol-E' diff --git a/frontend/src/config/viewersConfig.ts b/frontend/src/config/viewersConfig.ts index ce0b0b126..0f305a09a 100644 --- a/frontend/src/config/viewersConfig.ts +++ b/frontend/src/config/viewersConfig.ts @@ -10,7 +10,10 @@ const ViewerConfigEntrySchema = z.object( .string({ message: 'Each viewer must have a "manifest_url" field (string)' }) - .url({ message: '"manifest_url" must be a valid URL' }), + .refine(val => val.startsWith('/') || URL.canParse(val), { + message: + '"manifest_url" must be a valid URL or an absolute path starting with /' + }), instance_template_url: z .string({ message: '"instance_template_url" must be a string' }) .optional(), From f4e7f515e33dada10ca08aab395cb4d535eb7c40 Mon Sep 17 00:00:00 2001 From: allison-truhlar Date: Thu, 12 Feb 2026 11:40:08 -0500 Subject: [PATCH 044/104] =?UTF-8?q?fix:=20capitalize=20viewer=20names=20in?= =?UTF-8?q?=20manifests=20(validator=20=E2=86=92=20Validator,=20vole=20?= =?UTF-8?q?=E2=86=92=20Vol-E)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/public/viewers/validator.yaml | 8 ++++---- frontend/public/viewers/vole.yaml | 8 ++++---- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/frontend/public/viewers/validator.yaml b/frontend/public/viewers/validator.yaml index 58b15d595..8a3ee897c 100644 --- a/frontend/public/viewers/validator.yaml +++ b/frontend/public/viewers/validator.yaml @@ -1,8 +1,8 @@ viewer: - name: "validator" - version: "1.0.0" - repo: "https://github.com/ome/ome-ngff-validator" - template_url: "https://ome.github.io/ome-ngff-validator/?source={DATA_URL}" + name: 'Validator' + version: '1.0.0' + repo: 'https://github.com/ome/ome-ngff-validator' + template_url: 'https://ome.github.io/ome-ngff-validator/?source={DATA_URL}' capabilities: # Enumeration of OME-Zarr versions that can be loaded diff --git a/frontend/public/viewers/vole.yaml b/frontend/public/viewers/vole.yaml index e785144c3..fdd678036 100644 --- a/frontend/public/viewers/vole.yaml +++ b/frontend/public/viewers/vole.yaml @@ -1,8 +1,8 @@ viewer: - name: "vole" - version: "1.0.0" - repo: "https://github.com/allen-cell-animated/volume-viewer" - template_url: "https://volumeviewer.allencell.org/viewer?url={DATA_URL}" + name: 'Vol-E' + version: '1.0.0' + repo: 'https://github.com/allen-cell-animated/volume-viewer' + template_url: 'https://volumeviewer.allencell.org/viewer?url={DATA_URL}' capabilities: # Enumeration of OME-Zarr versions that can be loaded From 3080e8a19e81ae6d401ee0e83366cc04adbf72f0 Mon Sep 17 00:00:00 2001 From: allison-truhlar Date: Thu, 12 Feb 2026 11:40:20 -0500 Subject: [PATCH 045/104] fix: use viewer label for logo alt text and update test locators to match --- frontend/src/assets/{vole.png => vol-e.png} | Bin .../ui/BrowsePage/DataToolLinks.tsx | 2 +- .../tests/data-link-operations.spec.ts | 8 +--- .../ui-tests/tests/load-zarr-files.spec.ts | 40 ++++++------------ 4 files changed, 15 insertions(+), 35 deletions(-) rename frontend/src/assets/{vole.png => vol-e.png} (100%) diff --git a/frontend/src/assets/vole.png b/frontend/src/assets/vol-e.png similarity index 100% rename from frontend/src/assets/vole.png rename to frontend/src/assets/vol-e.png diff --git a/frontend/src/components/ui/BrowsePage/DataToolLinks.tsx b/frontend/src/components/ui/BrowsePage/DataToolLinks.tsx index 68f4b4091..050ec5669 100644 --- a/frontend/src/components/ui/BrowsePage/DataToolLinks.tsx +++ b/frontend/src/components/ui/BrowsePage/DataToolLinks.tsx @@ -57,7 +57,7 @@ export default function DataToolLinks({ to={url} > {`${viewer.displayName} diff --git a/frontend/ui-tests/tests/data-link-operations.spec.ts b/frontend/ui-tests/tests/data-link-operations.spec.ts index 714dda3e0..46ae24fa3 100644 --- a/frontend/ui-tests/tests/data-link-operations.spec.ts +++ b/frontend/ui-tests/tests/data-link-operations.spec.ts @@ -40,9 +40,7 @@ test.describe('Data Link Operations', () => { // Wait for zarr metadata to load await expect(page.getByText('zarr.json')).toBeVisible({ timeout: 10000 }); - await expect( - page.getByRole('link', { name: 'Neuroglancer logo' }) - ).toBeVisible(); + await expect(page.getByAltText(/neuroglancer/i)).toBeVisible(); const dataLinkToggle = page.getByRole('checkbox', { name: /data link/i }); const confirmButton = page.getByRole('button', { @@ -53,9 +51,7 @@ test.describe('Data Link Operations', () => { }); await test.step('Turn on automatic data links via the data link dialog', async () => { - const neuroglancerLink = page.getByRole('link', { - name: 'Neuroglancer logo' - }); + const neuroglancerLink = page.getByAltText(/neuroglancer/i); await neuroglancerLink.click(); // Confirm the data link creation in the dialog diff --git a/frontend/ui-tests/tests/load-zarr-files.spec.ts b/frontend/ui-tests/tests/load-zarr-files.spec.ts index 6105e7e09..1ae7a036a 100644 --- a/frontend/ui-tests/tests/load-zarr-files.spec.ts +++ b/frontend/ui-tests/tests/load-zarr-files.spec.ts @@ -32,10 +32,8 @@ test.describe('Zarr File Type Representation', () => { // Wait for zarr metadata to load (zarr.json file present indicates loaded) await expect(page.getByText('zarr.json')).toBeVisible({ timeout: 10000 }); - await expect( - page.getByRole('img', { name: /neuroglancer logo/i }) - ).toBeVisible({ timeout: 10000 }); - await expect(page.getByRole('img', { name: /vol-e logo/i })).toHaveCount(0); + await expect(page.getByAltText(/neuroglancer/i)).toBeVisible(); + await expect(page.getByAltText(/vol-e/i)).toHaveCount(0); }); test('Zarr V3 OME-Zarr should show all viewers except avivator', async ({ @@ -51,16 +49,10 @@ test.describe('Zarr File Type Representation', () => { // Wait for zarr metadata to load await expect(page.getByText('zarr.json')).toBeVisible({ timeout: 10000 }); - await expect( - page.getByRole('img', { name: /neuroglancer logo/i }) - ).toBeVisible({ timeout: 10000 }); - await expect(page.getByRole('img', { name: /vol-e logo/i })).toBeVisible(); - await expect( - page.getByRole('img', { name: /validator logo/i }) - ).toBeVisible(); - await expect(page.getByRole('img', { name: /avivator logo/i })).toHaveCount( - 0 - ); + await expect(page.getByAltText(/neuroglancer/i)).toBeVisible(); + await expect(page.getByAltText(/vol-e/i)).toBeVisible(); + await expect(page.getByAltText(/validator/i)).toBeVisible(); + await expect(page.getByAltText(/avivator/i)).toHaveCount(0); }); test('Zarr V2 Array should show only neuroglancer', async ({ @@ -76,10 +68,8 @@ test.describe('Zarr File Type Representation', () => { // Wait for zarr metadata to load await expect(page.getByText('.zarray')).toBeVisible({ timeout: 10000 }); - await expect( - page.getByRole('img', { name: /neuroglancer logo/i }) - ).toBeVisible({ timeout: 10000 }); - await expect(page.getByRole('img', { name: /vol-e logo/i })).toHaveCount(0); + await expect(page.getByAltText(/neuroglancer/i)).toBeVisible(); + await expect(page.getByAltText(/vol-e/i)).toHaveCount(0); }); test('Zarr V2 OME-Zarr should display all viewers including avivator', async ({ @@ -95,16 +85,10 @@ test.describe('Zarr File Type Representation', () => { // Wait for zarr metadata to load await expect(page.getByText('.zattrs')).toBeVisible({ timeout: 10000 }); - await expect( - page.getByRole('img', { name: /neuroglancer logo/i }) - ).toBeVisible({ timeout: 10000 }); - await expect(page.getByRole('img', { name: /vol-e logo/i })).toBeVisible(); - await expect( - page.getByRole('img', { name: /validator logo/i }) - ).toBeVisible(); - await expect( - page.getByRole('img', { name: /avivator logo/i }) - ).toBeVisible(); + await expect(page.getByAltText(/neuroglancer/i)).toBeVisible(); + await expect(page.getByAltText(/vol-e/i)).toBeVisible(); + await expect(page.getByAltText(/validator/i)).toBeVisible(); + await expect(page.getByAltText(/avivator/i)).toBeVisible(); }); test('Refresh button should update zarr metadata when .zattrs is modified', async ({ From 06c0ab1e23371c225c42cfd95f3719460b75c23a Mon Sep 17 00:00:00 2001 From: allison-truhlar Date: Thu, 12 Feb 2026 11:40:54 -0500 Subject: [PATCH 046/104] chore: prettier formatting --- frontend/src/__tests__/unitTests/viewersConfig.test.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/frontend/src/__tests__/unitTests/viewersConfig.test.ts b/frontend/src/__tests__/unitTests/viewersConfig.test.ts index 45db020ab..3277ccdb2 100644 --- a/frontend/src/__tests__/unitTests/viewersConfig.test.ts +++ b/frontend/src/__tests__/unitTests/viewersConfig.test.ts @@ -330,9 +330,7 @@ viewers: const result = parseViewersConfig(yaml); expect(result.viewers).toHaveLength(2); - expect(result.viewers[0].manifest_url).toBe( - '/viewers/neuroglancer.yaml' - ); + expect(result.viewers[0].manifest_url).toBe('/viewers/neuroglancer.yaml'); expect(result.viewers[1].manifest_url).toBe('/viewers/vizarr.yaml'); }); From 1a3dff90b518b9c374f290cbdea6fda9e05e2085 Mon Sep 17 00:00:00 2001 From: allison-truhlar Date: Wed, 25 Feb 2026 09:45:50 -0500 Subject: [PATCH 047/104] refactor: change manifest_urls in viewers config to the GH repo urls to mimic intended setup --- frontend/src/config/viewers.config.yaml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/frontend/src/config/viewers.config.yaml b/frontend/src/config/viewers.config.yaml index 008f3c6ab..9b0ec2324 100644 --- a/frontend/src/config/viewers.config.yaml +++ b/frontend/src/config/viewers.config.yaml @@ -10,13 +10,13 @@ # - label: Custom tooltip text (defaults to "View in {Name}") viewers: - - manifest_url: '/viewers/neuroglancer.yaml' + - manifest_url: 'https://raw.githubusercontent.com/JaneliaSciComp/fileglancer/7e460032d34b04ee7367000413d26ae0b989739b/frontend/public/viewers/neuroglancer.yaml' - - manifest_url: '/viewers/vizarr.yaml' + - manifest_url: 'https://raw.githubusercontent.com/JaneliaSciComp/fileglancer/7e460032d34b04ee7367000413d26ae0b989739b/frontend/public/viewers/vizarr.yaml' instance_template_url: 'https://janeliascicomp.github.io/viv/' - - manifest_url: '/viewers/validator.yaml' + - manifest_url: 'https://raw.githubusercontent.com/JaneliaSciComp/fileglancer/7e460032d34b04ee7367000413d26ae0b989739b/frontend/public/viewers/validator.yaml' label: 'View in OME-Zarr Validator' - - manifest_url: '/viewers/vole.yaml' + - manifest_url: 'https://raw.githubusercontent.com/JaneliaSciComp/fileglancer/7e460032d34b04ee7367000413d26ae0b989739b/frontend/public/viewers/vole.yaml' label: 'View in Vol-E' From 77502ae43def97d0643a1b27aa6df145c195e8cb Mon Sep 17 00:00:00 2001 From: allison-truhlar Date: Wed, 25 Feb 2026 09:52:12 -0500 Subject: [PATCH 048/104] fix: decouple DataToolLinks tests from exact manifest_url values in viewers confi --- .../componentTests/DataToolLinks.test.tsx | 98 +++++++++++-------- 1 file changed, 56 insertions(+), 42 deletions(-) diff --git a/frontend/src/__tests__/componentTests/DataToolLinks.test.tsx b/frontend/src/__tests__/componentTests/DataToolLinks.test.tsx index 48901abed..276eae05f 100644 --- a/frontend/src/__tests__/componentTests/DataToolLinks.test.tsx +++ b/frontend/src/__tests__/componentTests/DataToolLinks.test.tsx @@ -136,29 +136,36 @@ describe('DataToolLinks - Edge Cases', () => { beforeEach(() => { vi.clearAllMocks(); - // Mock loadManifestsFromUrls to return Map with manifests - // URLs must match those in viewers.config.yaml - mockCapabilityManifest.loadManifestsFromUrls.mockResolvedValue( - new Map([ - [ - '/viewers/neuroglancer.yaml', - { - viewer: { - name: 'Neuroglancer', - template_url: 'https://neuroglancer.com/#!{DATA_URL}' + // Mock loadManifestsFromUrls to return manifests keyed by the URLs it receives. + // This decouples the test from the exact manifest_url values in viewers.config.yaml. + const manifestsByName: Record = { + neuroglancer: { + viewer: { + name: 'Neuroglancer', + template_url: 'https://neuroglancer.com/#!{DATA_URL}' + } + }, + vizarr: { + viewer: { + name: 'Avivator', + template_url: 'https://vizarr.com/?url={DATA_URL}' + } + } + }; + + mockCapabilityManifest.loadManifestsFromUrls.mockImplementation( + async (urls: string[]) => { + const map = new Map(); + for (const url of urls) { + for (const [name, manifest] of Object.entries(manifestsByName)) { + if (url.includes(name)) { + map.set(url, manifest); + break; } } - ], - [ - '/viewers/vizarr.yaml', - { - viewer: { - name: 'Avivator', - template_url: 'https://vizarr.com/?url={DATA_URL}' - } - } - ] - ]) + } + return map; + } ); // Mock isCompatible to return true for all viewers @@ -245,29 +252,36 @@ describe('DataToolLinks - Expected Behavior', () => { beforeEach(() => { vi.clearAllMocks(); - // Mock loadManifestsFromUrls to return Map with manifests - // URLs must match those in viewers.config.yaml - mockCapabilityManifest.loadManifestsFromUrls.mockResolvedValue( - new Map([ - [ - '/viewers/neuroglancer.yaml', - { - viewer: { - name: 'Neuroglancer', - template_url: 'https://neuroglancer.com/#!{DATA_URL}' - } - } - ], - [ - '/viewers/vizarr.yaml', - { - viewer: { - name: 'Avivator', - template_url: 'https://vizarr.com/?url={DATA_URL}' + // Mock loadManifestsFromUrls to return manifests keyed by the URLs it receives. + // This decouples the test from the exact manifest_url values in viewers.config.yaml. + const manifestsByName: Record = { + neuroglancer: { + viewer: { + name: 'Neuroglancer', + template_url: 'https://neuroglancer.com/#!{DATA_URL}' + } + }, + vizarr: { + viewer: { + name: 'Avivator', + template_url: 'https://vizarr.com/?url={DATA_URL}' + } + } + }; + + mockCapabilityManifest.loadManifestsFromUrls.mockImplementation( + async (urls: string[]) => { + const map = new Map(); + for (const url of urls) { + for (const [name, manifest] of Object.entries(manifestsByName)) { + if (url.includes(name)) { + map.set(url, manifest); + break; } } - ] - ]) + } + return map; + } ); // Mock isCompatible to return true for all viewers From f024930f796a8f884524e6c9ab9b45f341d413d4 Mon Sep 17 00:00:00 2001 From: allison-truhlar Date: Mon, 2 Mar 2026 10:02:46 -0500 Subject: [PATCH 049/104] fix: correct templating for avivator/vizarr instance_template_url --- docs/ViewersConfiguration.md | 2 +- frontend/src/config/viewers.config.yaml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/ViewersConfiguration.md b/docs/ViewersConfiguration.md index d31111ef0..3abee5f4d 100644 --- a/docs/ViewersConfiguration.md +++ b/docs/ViewersConfiguration.md @@ -48,7 +48,7 @@ viewers: - manifest_url: "https://raw.githubusercontent.com/JaneliaSciComp/fileglancer/main/frontend/public/viewers/neuroglancer.yaml" - manifest_url: "https://raw.githubusercontent.com/JaneliaSciComp/fileglancer/main/frontend/public/viewers/vizarr.yaml" - instance_template_url: "https://janeliascicomp.github.io/viv/" + instance_template_url: "https://janeliascicomp.github.io/viv/?image_url={DATA_URL}" - manifest_url: "https://raw.githubusercontent.com/JaneliaSciComp/fileglancer/main/frontend/public/viewers/validator.yaml" label: "View in OME-Zarr Validator" diff --git a/frontend/src/config/viewers.config.yaml b/frontend/src/config/viewers.config.yaml index 9b0ec2324..290ccd3d8 100644 --- a/frontend/src/config/viewers.config.yaml +++ b/frontend/src/config/viewers.config.yaml @@ -13,7 +13,7 @@ viewers: - manifest_url: 'https://raw.githubusercontent.com/JaneliaSciComp/fileglancer/7e460032d34b04ee7367000413d26ae0b989739b/frontend/public/viewers/neuroglancer.yaml' - manifest_url: 'https://raw.githubusercontent.com/JaneliaSciComp/fileglancer/7e460032d34b04ee7367000413d26ae0b989739b/frontend/public/viewers/vizarr.yaml' - instance_template_url: 'https://janeliascicomp.github.io/viv/' + instance_template_url: 'https://janeliascicomp.github.io/viv/?image_url={DATA_URL}' - manifest_url: 'https://raw.githubusercontent.com/JaneliaSciComp/fileglancer/7e460032d34b04ee7367000413d26ae0b989739b/frontend/public/viewers/validator.yaml' label: 'View in OME-Zarr Validator' From 67ed39a28e0923f7de54f15fb02738980dff8fa6 Mon Sep 17 00:00:00 2001 From: allison-truhlar Date: Wed, 4 Mar 2026 13:29:40 -0500 Subject: [PATCH 050/104] chore: add spaces back in md file that were removed by prettier plugin --- docs/Development.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/Development.md b/docs/Development.md index f7c6608ff..f897f00b3 100644 --- a/docs/Development.md +++ b/docs/Development.md @@ -43,16 +43,16 @@ By default, Fileglancer provides access to each user's home directory without re ```yaml file_share_mounts: - - "~/" # User's home directory (default) + - "~/" # User's home directory (default) ``` You can add additional file share paths by editing your `config.yaml`: ```yaml file_share_mounts: - - "~/" # User's home directory - - "/groups/scicomp/data" # Shared data directory - - "/opt/data" # Another shared directory + - "~/" # User's home directory + - "/groups/scicomp/data" # Shared data directory + - "/opt/data" # Another shared directory ``` **How Home Directories Work:** From 69b09d05825934da686c743861f81f5cbdeccf55 Mon Sep 17 00:00:00 2001 From: allison-truhlar Date: Wed, 4 Mar 2026 13:44:30 -0500 Subject: [PATCH 051/104] fix: remove personal path --- .devcontainer/devcontainer.json | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index f69ec7235..191fe3b7a 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -32,8 +32,7 @@ }, "mounts": [ "source=${localEnv:HOME}/.claude,target=/home/vscode/.claude,type=bind", - "source=fileglancer-pixi,target=${containerWorkspaceFolder}/.pixi,type=volume", - "source=/groups/scicompsoft/home/truhlara/gh-repos/capability-manifest,target=/workspaces/capability-manifest,type=bind" + "source=fileglancer-pixi,target=${containerWorkspaceFolder}/.pixi,type=volume" ], "remoteEnv": { "NODE_OPTIONS": "--max-old-space-size=4096", From 774a1ac649339e9c92a943c80c7cf409659c983b Mon Sep 17 00:00:00 2001 From: allison-truhlar Date: Wed, 4 Mar 2026 21:02:01 +0000 Subject: [PATCH 052/104] feat: support local frontend/viewers.config.yaml override at build time Add a gitignored frontend/viewers.config.yaml that, when present, is bundled instead of the committed default. Uses a Vite regex alias to correctly handle the ?raw query suffix. Includes a template file users can copy and customize without touching committed source. --- .gitignore | 1 + frontend/src/config/viewers.config.yaml | 23 ++++++---------------- frontend/viewers.config.yaml.template | 26 +++++++++++++++++++++++++ frontend/vite.config.ts | 15 +++++++++++--- 4 files changed, 45 insertions(+), 20 deletions(-) create mode 100644 frontend/viewers.config.yaml.template diff --git a/.gitignore b/.gitignore index 4332968fb..76fb01fe6 100644 --- a/.gitignore +++ b/.gitignore @@ -143,6 +143,7 @@ style/tailwind_converted.css # Configs config.yaml +frontend/viewers.config.yaml # Claude Code .claude diff --git a/frontend/src/config/viewers.config.yaml b/frontend/src/config/viewers.config.yaml index 290ccd3d8..689d36ef3 100644 --- a/frontend/src/config/viewers.config.yaml +++ b/frontend/src/config/viewers.config.yaml @@ -1,22 +1,11 @@ -# Fileglancer OME-Zarr Viewers Configuration -# -# Each viewer entry requires: -# - manifest_url: URL or absolute path to a capability manifest YAML file -# Use absolute paths (e.g. /viewers/neuroglancer.yaml) for manifests bundled -# in the public/ directory, or full URLs for externally hosted manifests. -# Optional overrides: -# - instance_template_url: Override the viewer's template_url from the manifest -# - logo: Filename of logo in frontend/src/assets/ (defaults to {normalized_name}.png) -# - label: Custom tooltip text (defaults to "View in {Name}") +# Default Fileglancer OME-Zarr Viewers Configuration +# Can be overridden by providing a custom viewers.config.yaml file in the frontend/ directory viewers: - - manifest_url: 'https://raw.githubusercontent.com/JaneliaSciComp/fileglancer/7e460032d34b04ee7367000413d26ae0b989739b/frontend/public/viewers/neuroglancer.yaml' + - manifest_url: 'https://raw.githubusercontent.com/JaneliaSciComp/fileglancer/viewers-config/frontend/public/viewers/neuroglancer.yaml' - - manifest_url: 'https://raw.githubusercontent.com/JaneliaSciComp/fileglancer/7e460032d34b04ee7367000413d26ae0b989739b/frontend/public/viewers/vizarr.yaml' - instance_template_url: 'https://janeliascicomp.github.io/viv/?image_url={DATA_URL}' + - manifest_url: 'https://raw.githubusercontent.com/JaneliaSciComp/fileglancer/viewers-config/frontend/public/viewers/vizarr.yaml' - - manifest_url: 'https://raw.githubusercontent.com/JaneliaSciComp/fileglancer/7e460032d34b04ee7367000413d26ae0b989739b/frontend/public/viewers/validator.yaml' - label: 'View in OME-Zarr Validator' + - manifest_url: 'https://raw.githubusercontent.com/JaneliaSciComp/fileglancer/viewers-config/frontend/public/viewers/validator.yaml' - - manifest_url: 'https://raw.githubusercontent.com/JaneliaSciComp/fileglancer/7e460032d34b04ee7367000413d26ae0b989739b/frontend/public/viewers/vole.yaml' - label: 'View in Vol-E' + - manifest_url: 'https://raw.githubusercontent.com/JaneliaSciComp/fileglancer/viewers-config/frontend/public/viewers/vole.yaml' diff --git a/frontend/viewers.config.yaml.template b/frontend/viewers.config.yaml.template new file mode 100644 index 000000000..8483e40fc --- /dev/null +++ b/frontend/viewers.config.yaml.template @@ -0,0 +1,26 @@ +# Fileglancer OME-Zarr Viewers Configuration +# +# Copy this file to frontend/viewers.config.yaml to override the default +# viewer configuration, located at frontend/src/config/viewers.config.yaml. +# +# After editing, rebuild with: pixi run node-build +# (or use watch mode: pixi run dev-watch) +# +# Each viewer entry requires: +# - manifest_url: URL or absolute path to a capability manifest YAML file +# Use absolute paths (e.g. /viewers/neuroglancer.yaml) for manifests bundled +# in the public/ directory, or full URLs for externally hosted manifests. +# +# Optional: +# - instance_template_url: Override the viewer's template_url from the manifest +# - logo: Filename of logo in frontend/src/assets/ (defaults to {normalized_name}.png) +# - label: Custom tooltip text (defaults to "View in {Name}") + +viewers: + - manifest_url: 'https://raw.githubusercontent.com/JaneliaSciComp/fileglancer/viewers-config/frontend/public/viewers/neuroglancer.yaml' + + - manifest_url: 'https://raw.githubusercontent.com/JaneliaSciComp/fileglancer/viewers-config/frontend/public/viewers/vizarr.yaml' + + - manifest_url: 'https://raw.githubusercontent.com/JaneliaSciComp/fileglancer/viewers-config/frontend/public/viewers/validator.yaml' + + - manifest_url: 'https://raw.githubusercontent.com/JaneliaSciComp/fileglancer/viewers-config/frontend/public/viewers/vole.yaml' diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 3673f7d48..3cc00dae8 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -1,16 +1,25 @@ import { defineConfig } from 'vite'; import path from 'path'; +import { existsSync } from 'fs'; import react from '@vitejs/plugin-react'; import { nodePolyfills } from 'vite-plugin-node-polyfills'; +const overridePath = path.resolve(__dirname, './viewers.config.yaml'); +const defaultConfigPath = path.resolve(__dirname, './src/config/viewers.config.yaml'); +const viewersConfigPath = existsSync(overridePath) ? overridePath : defaultConfigPath; + // https://vite.dev/config/ export default defineConfig({ base: '/', plugins: [react(), nodePolyfills({ include: ['path'] })], resolve: { - alias: { - '@': path.resolve(__dirname, './src') - } + alias: [ + { + find: /^@\/config\/viewers\.config\.yaml(\?.*)?$/, + replacement: viewersConfigPath + '$1' + }, + { find: '@', replacement: path.resolve(__dirname, './src') } + ] }, css: { lightningcss: { From 68050350930e9cf03add57fe6e67576fc19e3708 Mon Sep 17 00:00:00 2001 From: allison-truhlar Date: Wed, 4 Mar 2026 21:02:05 +0000 Subject: [PATCH 053/104] fix: rename validator manifest viewer.name to OME-Zarr Validator --- frontend/public/viewers/validator.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/public/viewers/validator.yaml b/frontend/public/viewers/validator.yaml index 8a3ee897c..b12987a73 100644 --- a/frontend/public/viewers/validator.yaml +++ b/frontend/public/viewers/validator.yaml @@ -1,5 +1,5 @@ viewer: - name: 'Validator' + name: 'OME-Zarr Validator' version: '1.0.0' repo: 'https://github.com/ome/ome-ngff-validator' template_url: 'https://ome.github.io/ome-ngff-validator/?source={DATA_URL}' From e9f96492bb9cfeb59857236d8d11a291b7cadf56 Mon Sep 17 00:00:00 2001 From: allison-truhlar Date: Wed, 4 Mar 2026 21:02:09 +0000 Subject: [PATCH 054/104] docs: update viewers configuration docs to reflect override pattern --- CLAUDE.md | 7 ++++--- docs/Development.md | 8 +++++--- docs/ViewersConfiguration.md | 23 ++++++++++++++++------- 3 files changed, 25 insertions(+), 13 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index ecfdf6fd6..c72d5321c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -180,10 +180,11 @@ Fileglancer uses a manifest-based viewer configuration system. Each viewer is de To customize viewers: -1. Edit `frontend/src/config/viewers.config.yaml` (add/remove `manifest_url` entries, override URLs or labels) -2. Rebuild application: `pixi run node-build` +1. Copy `frontend/viewers.config.yaml.template` to `frontend/viewers.config.yaml` +2. Edit `frontend/viewers.config.yaml` (add/remove `manifest_url` entries, override URLs or labels) +3. Rebuild application: `pixi run node-build` -The default configuration includes Neuroglancer, Avivator, OME-Zarr Validator, and Vol-E viewers. The config file is bundled at build time. +`frontend/viewers.config.yaml` is gitignored so customizations do not conflict with upstream updates. When it exists, it takes precedence over the committed default at `frontend/src/config/viewers.config.yaml`. The config file is bundled at build time. ## Pixi Environments diff --git a/docs/Development.md b/docs/Development.md index f897f00b3..d4ca794d5 100644 --- a/docs/Development.md +++ b/docs/Development.md @@ -72,11 +72,13 @@ Fileglancer supports dynamic configuration of OME-Zarr viewers through `viewers. **Quick Setup:** -1. Edit the configuration file at `frontend/src/config/viewers.config.yaml` to enable/disable viewers or customize URLs +1. Copy the template into the frontend directory: `cp frontend/viewers.config.yaml.template frontend/viewers.config.yaml` -2. Rebuild the application: `pixi run node-build` or use watch mode in development: `pixi run dev-watch` +2. Edit `frontend/viewers.config.yaml` to enable/disable viewers or customize URLs -**Note:** The configuration file is bundled at build time, so changes require rebuilding the application. The default configuration includes Neuroglancer, Avivator, OME-Zarr Validator, and Vol-E viewers. +3. Rebuild the application: `pixi run node-build` or use watch mode in development: `pixi run dev-watch` + +**Note:** The configuration file is bundled at build time, so changes require rebuilding the application. `frontend/viewers.config.yaml` is gitignored so your customizations will not conflict with upstream updates. The default configuration includes Neuroglancer, Avivator, OME-Zarr Validator, and Vol-E viewers. For detailed configuration options, examples, and documentation on adding custom viewers, see [ViewersConfiguration.md](ViewersConfiguration.md). diff --git a/docs/ViewersConfiguration.md b/docs/ViewersConfiguration.md index 3abee5f4d..bd91a57eb 100644 --- a/docs/ViewersConfiguration.md +++ b/docs/ViewersConfiguration.md @@ -15,16 +15,24 @@ Each viewer is defined by a **capability manifest** hosted at a URL. The configu ## Quick Start -1. Edit the configuration file at `frontend/src/config/viewers.config.yaml` -2. Rebuild the application: `pixi run node-build` +1. Copy the template into the frontend directory: `cp frontend/viewers.config.yaml.template frontend/viewers.config.yaml` +2. Edit `frontend/viewers.config.yaml` to customize viewers +3. Rebuild the application: `pixi run node-build` ## Configuration File ### Location -`frontend/src/config/viewers.config.yaml` +There are two config locations, with the `frontend/` override taking precedence at build time: -**Important:** This file is bundled at build time. Changes require rebuilding the application. +| Location | Purpose | +| -------- | ------- | +| `frontend/viewers.config.yaml` | **Local override** — gitignored, safe to customize without merge conflicts | +| `frontend/src/config/viewers.config.yaml` | **Default config** — committed source file, used when no override exists | + +Copy `frontend/viewers.config.yaml.template` to `frontend/viewers.config.yaml` to create a local override. This file is listed in `.gitignore` so your customizations will not conflict with upstream updates. + +**Important:** The config is bundled at build time. Changes require rebuilding the application. ### Structure @@ -233,9 +241,10 @@ viewers: When developing with custom configurations: -1. Edit `frontend/src/config/viewers.config.yaml` -2. Rebuild frontend: `pixi run node-build` or use watch mode: `pixi run dev-watch` -3. Check the browser console for viewer initialization messages +1. Copy `frontend/viewers.config.yaml.template` to `frontend/viewers.config.yaml` +2. Edit `frontend/viewers.config.yaml` +3. Rebuild frontend: `pixi run node-build` or use watch mode: `pixi run dev-watch` +4. Check the browser console for viewer initialization messages ### Validation From 59d64ac11bf9f8ec84fcd48c8d9d6b6cac6cd92f Mon Sep 17 00:00:00 2001 From: allison-truhlar Date: Wed, 4 Mar 2026 21:02:12 +0000 Subject: [PATCH 055/104] refactor: log viewer initialization errors once at the top level only --- frontend/src/contexts/ViewersContext.tsx | 16 +++++----------- 1 file changed, 5 insertions(+), 11 deletions(-) diff --git a/frontend/src/contexts/ViewersContext.tsx b/frontend/src/contexts/ViewersContext.tsx index 2bbd6118d..0570d840e 100644 --- a/frontend/src/contexts/ViewersContext.tsx +++ b/frontend/src/contexts/ViewersContext.tsx @@ -59,23 +59,18 @@ async function loadViewersConfig(): Promise { 'Using custom viewers configuration from src/config/viewers.config.yaml' ); } catch (error) { - log.info( - 'No custom viewers.config.yaml found, using default configuration' + throw new Error( + `Failed to load viewers configuration: ${error instanceof Error ? error.message : 'Unknown error'}` ); - return [ - { - manifest_url: - 'https://raw.githubusercontent.com/JaneliaSciComp/fileglancer/main/frontend/public/viewers/neuroglancer.yaml' - } - ]; } try { const config = parseViewersConfig(configYaml); return config.viewers; } catch (error) { - log.error('Error parsing viewers configuration:', error); - throw error; + throw new Error( + `Failed to parse viewers configuration: ${error instanceof Error ? error.message : 'Unknown error'}` + ); } } @@ -113,7 +108,6 @@ export function ViewersProvider({ manifestsMap = await loadManifestsFromUrls(manifestUrls); log.info(`Loaded ${manifestsMap.size} viewer capability manifests`); } catch (manifestError) { - log.error('Failed to load capability manifests:', manifestError); throw new Error( `Failed to load viewer manifests: ${manifestError instanceof Error ? manifestError.message : 'Unknown error'}` ); From a1324c59045d4d134ea82ac5d8ca24eb2bb00d7f Mon Sep 17 00:00:00 2001 From: allison-truhlar Date: Wed, 4 Mar 2026 21:02:16 +0000 Subject: [PATCH 056/104] chore: add fileglancer domain to devcontainer firewall allowlist --- .devcontainer/init-firewall.sh | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.devcontainer/init-firewall.sh b/.devcontainer/init-firewall.sh index 38762ba34..ba882fabb 100755 --- a/.devcontainer/init-firewall.sh +++ b/.devcontainer/init-firewall.sh @@ -84,6 +84,8 @@ ALLOWED_DOMAINS=( "conda-mapping.prefix.dev" "prefix.dev" "repo.prefix.dev" + # Fileglancer + "fileglancer.int.janelia.org" ) for domain in "${ALLOWED_DOMAINS[@]}"; do From a9deb499c3117d13ac4c3d7f7a2e769fd43a74c4 Mon Sep 17 00:00:00 2001 From: allison-truhlar Date: Wed, 4 Mar 2026 16:02:58 -0500 Subject: [PATCH 057/104] chore: prettier formatting --- frontend/vite.config.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 3cc00dae8..44acbbecd 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -5,8 +5,13 @@ import react from '@vitejs/plugin-react'; import { nodePolyfills } from 'vite-plugin-node-polyfills'; const overridePath = path.resolve(__dirname, './viewers.config.yaml'); -const defaultConfigPath = path.resolve(__dirname, './src/config/viewers.config.yaml'); -const viewersConfigPath = existsSync(overridePath) ? overridePath : defaultConfigPath; +const defaultConfigPath = path.resolve( + __dirname, + './src/config/viewers.config.yaml' +); +const viewersConfigPath = existsSync(overridePath) + ? overridePath + : defaultConfigPath; // https://vite.dev/config/ export default defineConfig({ From 27c0d81263e057f13b593ae407d42c0f9d72eb3a Mon Sep 17 00:00:00 2001 From: allison-truhlar Date: Fri, 6 Mar 2026 11:51:38 -0500 Subject: [PATCH 058/104] refactor: extract resolveViewersConfigPath utility from vite.config.ts - necessary for supporting unit test of how vite resolves the viewers.config.yaml file path at startup --- .../src/config/resolveViewersConfigPath.ts | 18 ++++++++++++++++++ frontend/vite.config.ts | 11 ++--------- 2 files changed, 20 insertions(+), 9 deletions(-) create mode 100644 frontend/src/config/resolveViewersConfigPath.ts diff --git a/frontend/src/config/resolveViewersConfigPath.ts b/frontend/src/config/resolveViewersConfigPath.ts new file mode 100644 index 000000000..2ed2d0aa0 --- /dev/null +++ b/frontend/src/config/resolveViewersConfigPath.ts @@ -0,0 +1,18 @@ +import { existsSync } from 'fs'; +import path from 'path'; + +/** + * Returns the path to the viewers config YAML to use. + * If a custom viewers.config.yaml exists at the frontend root it takes + * precedence over the committed default in src/config/. + * + * @param frontendDir - Absolute path to the frontend/ directory. + */ +export function resolveViewersConfigPath(frontendDir: string): string { + const overridePath = path.resolve(frontendDir, 'viewers.config.yaml'); + const defaultPath = path.resolve( + frontendDir, + 'src/config/viewers.config.yaml' + ); + return existsSync(overridePath) ? overridePath : defaultPath; +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 44acbbecd..0eb172e31 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -1,17 +1,10 @@ import { defineConfig } from 'vite'; import path from 'path'; -import { existsSync } from 'fs'; import react from '@vitejs/plugin-react'; import { nodePolyfills } from 'vite-plugin-node-polyfills'; +import { resolveViewersConfigPath } from './src/config/resolveViewersConfigPath'; -const overridePath = path.resolve(__dirname, './viewers.config.yaml'); -const defaultConfigPath = path.resolve( - __dirname, - './src/config/viewers.config.yaml' -); -const viewersConfigPath = existsSync(overridePath) - ? overridePath - : defaultConfigPath; +const viewersConfigPath = resolveViewersConfigPath(__dirname); // https://vite.dev/config/ export default defineConfig({ From d4572bfb16f51b0d91231acc7b0e14702fdbcfee Mon Sep 17 00:00:00 2001 From: allison-truhlar Date: Fri, 6 Mar 2026 11:52:05 -0500 Subject: [PATCH 059/104] test: add unit tests for viewers.config.yaml override path resolution - includes mock viewers.config.yaml file for CI --- .../src/__tests__/mocks/viewers.config.yaml | 4 +++ .../unitTests/viewersConfigOverride.test.ts | 25 +++++++++++++++++++ 2 files changed, 29 insertions(+) create mode 100644 frontend/src/__tests__/mocks/viewers.config.yaml create mode 100644 frontend/src/__tests__/unitTests/viewersConfigOverride.test.ts diff --git a/frontend/src/__tests__/mocks/viewers.config.yaml b/frontend/src/__tests__/mocks/viewers.config.yaml new file mode 100644 index 000000000..7062d5f69 --- /dev/null +++ b/frontend/src/__tests__/mocks/viewers.config.yaml @@ -0,0 +1,4 @@ +# Test fixture: mock viewers.config.yaml used to verify the override mechanism. +# This file simulates a custom frontend/viewers.config.yaml provided by the user. +viewers: + - manifest_url: 'https://example.com/mock-viewer.yaml' diff --git a/frontend/src/__tests__/unitTests/viewersConfigOverride.test.ts b/frontend/src/__tests__/unitTests/viewersConfigOverride.test.ts new file mode 100644 index 000000000..9fde5168d --- /dev/null +++ b/frontend/src/__tests__/unitTests/viewersConfigOverride.test.ts @@ -0,0 +1,25 @@ +import { describe, it, expect } from 'vitest'; +import path from 'path'; +import { resolveViewersConfigPath } from '@/config/resolveViewersConfigPath'; + +// Use the mocks directory as a stand-in for the frontend root in override tests. +// It contains a viewers.config.yaml fixture, so existsSync returns true there — +// exactly as it would for a real user-provided override at frontend/viewers.config.yaml. +const mocksDir = path.resolve(process.cwd(), 'src/__tests__/mocks'); + +// A directory guaranteed not to contain viewers.config.yaml, to test the fallback. +const noOverrideDir = path.resolve(process.cwd(), 'src/__tests__/unitTests'); + +describe('resolveViewersConfigPath', () => { + it('returns the override path when viewers.config.yaml exists in the frontend root', () => { + const result = resolveViewersConfigPath(mocksDir); + expect(result).toBe(path.resolve(mocksDir, 'viewers.config.yaml')); + }); + + it('returns the default config path when no override file is present', () => { + const result = resolveViewersConfigPath(noOverrideDir); + expect(result).toBe( + path.resolve(noOverrideDir, 'src/config/viewers.config.yaml') + ); + }); +}); From b31e918eb043b9307d9cb3d82df6ff90c159ee5d Mon Sep 17 00:00:00 2001 From: allison-truhlar Date: Fri, 6 Mar 2026 11:52:15 -0500 Subject: [PATCH 060/104] chore: add s3.janelia.org to devcontainer firewall allowlist --- .devcontainer/init-firewall.sh | 1 + 1 file changed, 1 insertion(+) diff --git a/.devcontainer/init-firewall.sh b/.devcontainer/init-firewall.sh index ba882fabb..cace824d2 100755 --- a/.devcontainer/init-firewall.sh +++ b/.devcontainer/init-firewall.sh @@ -86,6 +86,7 @@ ALLOWED_DOMAINS=( "repo.prefix.dev" # Fileglancer "fileglancer.int.janelia.org" + "s3.janelia.org" ) for domain in "${ALLOWED_DOMAINS[@]}"; do From d5f05dc01820e550da9ae68d078e1692e1dafc19 Mon Sep 17 00:00:00 2001 From: allison-truhlar Date: Fri, 6 Mar 2026 12:16:21 -0500 Subject: [PATCH 061/104] chore: update and trim down the frontend/CLAUDE.md file --- frontend/CLAUDE.md | 533 ++++++--------------------------------------- 1 file changed, 69 insertions(+), 464 deletions(-) diff --git a/frontend/CLAUDE.md b/frontend/CLAUDE.md index 5cd965e0d..84f820c7d 100644 --- a/frontend/CLAUDE.md +++ b/frontend/CLAUDE.md @@ -1,231 +1,36 @@ # CLAUDE.md - Frontend -This file provides guidance to Claude Code when working with the frontend code in this directory. - > **Note**: This is a subdirectory-specific guide. For full project context, look for a CLAUDE.md in the root directory. -## Directory Overview - -This directory contains the React/TypeScript frontend application for Fileglancer. The built output is copied to `../fileglancer/ui/` and served by the FastAPI backend. - -## Quick Start - -```bash -# From project root -cd .. -pixi run dev-install # Install and build -pixi run dev-watch # Watch mode for frontend changes -pixi run dev-launch # Launch backend + serve frontend -pixi run test-frontend # Vitest frontend unit tests (npm test) -pixi run test-ui # Playwright integration tests -pixi run test-ui -- tests/specific.spec.ts # Run specific test -pixi run node-eslint-check # Check eslint rules -pixi run node-eslint-write # Modify according to eslint rules -pixi run node-prettier-write # Modify according to prettier rules -./clean.sh # Clean all build directories -``` - -## Directory Structure - -``` -frontend/ -├── src/ -│ ├── main.tsx # Application entry point -│ ├── App.tsx # Root component with routing -│ ├── index.css # Global styles -│ ├── logger.ts # Logging utility (loglevel) -│ ├── omezarr-helper.ts # OME-Zarr/NGFF utilities -│ ├── shared.types.ts # Shared TypeScript types -│ │ -│ ├── components/ # Page-level components -│ │ ├── Browse.tsx -│ │ ├── Help.tsx -│ │ ├── Jobs.tsx -│ │ ├── Links.tsx -│ │ ├── Preferences.tsx -│ │ ├── Notifications.tsx -│ │ └── ui/ # Feature-specific UI components -│ │ ├── BrowsePage/ # File browser components -│ │ ├── Dialogs/ # Modal dialogs -│ │ ├── Menus/ # Context and action menus -│ │ ├── Navbar/ # Top navigation -│ │ ├── Sidebar/ # File browser sidebar -│ │ ├── Table/ # Table components -│ │ ├── widgets/ # Reusable UI widgets -│ │ ├── PreferencesPage/ -│ │ ├── PropertiesDrawer/ -│ │ └── Notifications/ -│ │ -│ ├── contexts/ # React Context providers -│ │ ├── FileBrowserContext.tsx -│ │ ├── ZonesAndFspMapContext.tsx -│ │ ├── PreferencesContext.tsx -│ │ ├── TicketsContext.tsx -│ │ ├── ProxiedPathContext.tsx -│ │ ├── ExternalBucketContext.tsx -│ │ ├── ProfileContext.tsx -│ │ ├── ServerHealthContext.tsx -│ │ ├── NotificationsContext.tsx -│ │ ├── OpenFavoritesContext.tsx -│ │ └── CookiesContext.tsx -│ │ -│ ├── hooks/ # Custom React hooks -│ │ -│ ├── queries/ # TanStack Query hooks -│ │ └── (API query hooks) -│ │ -│ ├── layouts/ # Layout components -│ │ ├── MainLayout.tsx -│ │ ├── BrowsePageLayout.tsx -│ │ └── OtherPagesLayout.tsx -│ │ -│ ├── utils/ # Utility functions -│ │ -│ ├── constants/ # Application constants -│ │ -│ ├── assets/ # Static assets (images, icons) -│ │ -│ └── __tests__/ # Test files -│ ├── setup.ts -│ ├── test-utils.tsx -│ ├── componentTests/ -│ ├── unitTests/ -│ └── mocks/ -│ └── handlers.ts # MSW mock handlers -│ -├── ui-tests/ # Playwright E2E tests -├── public/ # Static public assets -├── index.html # HTML entry point -├── vite.config.ts # Vite configuration -├── tailwind.config.js # Tailwind CSS theme -├── eslint.config.mjs # ESLint configuration -├── prettier.config.mjs # Prettier configuration -├── tsconfig.json # TypeScript configuration -└── package.json # NPM dependencies and scripts -``` - -## Technology Stack - -### Core - -- **React 18.3.1** - UI framework with hooks and concurrent features -- **TypeScript 5.8** - Type-safe JavaScript -- **Vite** (Rolldown) - Fast Rust-based bundler (Rollup alternative) -- **React Router 7.4** - Client-side routing - -### State Management - -- **React Context API** - Application state (see `src/contexts/`) -- **TanStack Query v5** - Server state management, data fetching, caching - - Query hooks in `src/queries/` - - DevTools available in development mode - -### UI & Styling - -- **Material Tailwind v3** (beta) - Component library -- **Tailwind CSS 3.4** - Utility-first CSS framework -- **React Icons 5.5** - Icon library -- **React Hot Toast 2.5** - Notifications/toasts -- **React Resizable Panels 3.0** - Resizable layout panels - -### Data & Visualization - -- **TanStack Table v8** - Headless table/data grid -- **ome-zarr.js 0.0.14** - OME-NGFF/Zarr visualization -- **zarrita 0.5** - Zarr file format support -- **React Syntax Highlighter 15.6** - Code display - -### Testing - -- **Vitest 3.1** - Fast unit test runner (Vite-native) -- **React Testing Library 16.3** - Component testing utilities -- **Happy DOM 18.0** - Fast DOM implementation for tests -- **MSW 2.10** - API mocking for tests -- **@testing-library/jest-dom 6.6** - DOM matchers -- **@testing-library/user-event 14.6** - User interaction simulation -- **@types/react 18.3** - React type definitions -- **@types/react-dom 18.3** - React DOM type definitions -- **Playwright** (in ui-tests/) - E2E browser testing - -### Development Tools - -- **ESLint 9.26** - Linting with TypeScript/React plugins -- **Prettier 3.5** - Code formatting -- **Lefthook 1.12** - Git hooks manager - ## Development Patterns ### Import Formatting -**Separate imports for functions and types:** - -When importing both functions/values and types from the same namespace, use separate import lines: - -```typescript -// Good - separate imports -import { useState, useEffect } from 'react'; -import type { FC, ReactNode } from 'react'; - -import { useQuery } from '@tanstack/react-query'; -import type { QueryClient } from '@tanstack/react-query'; - -// Avoid - mixing functions and types -import { useState, useEffect, type FC, type ReactNode } from 'react'; -``` - -This improves readability and makes it clear which imports are type-only. +Use separate import lines for values and types from the same package (e.g. `import { useQuery } from '...'` and `import type { UseQueryResult } from '...'` on separate lines). ### URL Construction and Encoding -**Key Principle**: URL encoding must happen at the point of URL construction using utility functions, not manually. - -**Data Flow**: User-controlled data (file paths, FSP names, etc.) flows through the application in raw, unencoded form. Encoding is applied by URL construction utilities. - -**URL Construction Utilities** (`src/utils/index.ts` and `src/utils/pathHandling.ts`): +Never manually construct URLs with template strings. Use utility functions (`src/utils/index.ts`, `src/utils/pathHandling.ts`): -1. **`buildApiUrl(basePath, pathSegments?, queryParams?)`** - For internal API requests - - Encodes path segments with `encodeURIComponent()` (including `/`) - - Uses `URLSearchParams` for query parameters - - Returns relative URLs (e.g., `/api/files/myFSP?subpath=file.txt`) - - **Why it encodes `/`**: FastAPI automatically URL-decodes path parameters, so full encoding is required - - **Use for**: All `sendFetchRequest()` calls to internal APIs +1. **`buildUrl`** (`src/utils/index.ts`) - General-purpose URL builder with two overloads: -2. **`buildExternalUrlWithQuery(baseUrl, queryParams?)`** - For form/query-based external URLs - - Takes absolute URLs as base - - Only supports query parameters (no path segments) - - Uses `URLSearchParams` for query encoding - - Returns absolute URLs (e.g., `https://viewer.com?url=...`) - - **Use for**: External form submissions, validators, and web apps that accept data as query params + - **Overload 1**: `buildUrl(baseUrl, singlePathSegment | null, queryParams | null)` — single path segment encoded with `encodeURIComponent`, plus optional query params via `URLSearchParams` + ```typescript + buildUrl('/api/files/', 'myFSP', { subpath: 'folder/file.txt' }) + // → '/api/files/myFSP?subpath=folder%2Ffile.txt' + buildUrl('/api/endpoint', null, { key: 'value' }) + // → '/api/endpoint?key=value' + ``` + - **Overload 2**: `buildUrl(baseUrl, multiSegmentPathString)` — multi-segment path encoded with `escapePathForUrl` (preserves `/`) + ```typescript + buildUrl('https://s3.example.com/bucket', 'folder/file 100%.zarr') + // → 'https://s3.example.com/bucket/folder/file%20100%25.zarr' + ``` + - **Use for**: All internal API calls and any external URL that needs path or query encoding -3. **`buildExternalUrlWithPath(baseUrl, pathSegment?, queryParams?)`** - For S3-style external URLs - - Takes absolute URLs as base - - Path segments are encoded while preserving `/` as path separator - - Optional query parameters using `URLSearchParams` - - Returns absolute URLs (e.g., `https://s3.example.com/bucket/folder/file.zarr`) - - **Use for**: S3-compatible storage, cloud bucket URLs with path-based resource access +2. **`getFileURL(fspName, filePath?)`** - Absolute `/api/content/` URL for file content; use when passing URLs to OME-Zarr viewers -4. **`getFileURL(fspName, filePath?)`** - For browser-accessible file content URLs - - Uses `escapePathForUrl()` which preserves `/` as path separator - - Returns absolute URLs using `window.location.origin` - - Specifically for `/api/content/` endpoint - - **Use for**: File content URLs displayed to users or used in OME-Zarr viewers - -5. **`escapePathForUrl(path)`** - For path-style URLs (preserves `/`) - - Encodes each path segment separately - - Preserves forward slashes as path separators - - **Use for**: Constructing file paths within URLs - -**Best Practices**: - -- **Always use utility functions** - Never manually construct URLs with template strings -- **Choose the right utility**: - - Internal API calls → `buildApiUrl` - - Query-based external URLs → `buildExternalUrlWithQuery` - - S3-style external URLs → `buildExternalUrlWithPath` - - File content URLs → `getFileURL` - - Manual path construction → `escapePathForUrl` -- **No double encoding**: Functions that receive URLs (like `sendFetchRequest`) do not re-encode -- **Backend URLs are ready**: URLs from backend API responses are already encoded +3. **`escapePathForUrl(path)`** - Encodes path segments while preserving `/`; use when `buildUrl` overload 2 isn't sufficient ### Component Guidelines @@ -246,272 +51,72 @@ This improves readability and makes it clear which imports are type-only. - Good: `function MyComponent() { ... }` - Avoid: `function MyComponent(): JSX.Element { ... }` -### State Management Patterns - -**When to use what:** - -- **React Context** (`src/contexts/`) - Global UI state, dependency injection (e.g., of server state) - - Follow provider pattern used in existing contexts -- **TanStack Query** (`src/queries/`) - Server data fetching, caching, synchronization - - Use for all API calls - - Define query/mutation hooks in `src/queries/` - - Leverage automatic refetching, caching, and background updates -- **Component State** (`useState`) - Local UI state that doesn't need to be shared -- **URL State** (React Router) - Navigation state, filters, search params - ### API Integration with TanStack Query -**Pattern for data fetching:** - -```typescript -// In src/queries/useMyData.ts -// default `staleTime` set to 30 seconds in /src/main.tsx -// only override if good reason to (see example below) -import { useQuery } from '@tanstack/react-query'; -import { buildApiUrl, sendFetchRequest } from '@/utils'; - -export function useMyData(fspName: string, filePath?: string) { - return useQuery({ - queryKey: ['my-data', fspName, filePath], - queryFn: async ({ signal }) => { - // Use buildApiUrl for proper URL encoding - const url = buildApiUrl( - '/api/files/', - [fspName], - filePath ? { subpath: filePath } : undefined - ); +**URL construction:** Use `buildUrl` from `@/utils` for all API URLs: +- `buildUrl('/api/files/', fspName, { subpath: path })` — path segment + query params +- `buildUrl('/api/resource/', id, null)` — path segment only - // Use sendFetchRequest for session handling and health checks - const response = await sendFetchRequest(url, 'GET', undefined, { - signal - }); +**Query utilities** (`src/queries/queryUtils.ts`): +- `sendRequestAndThrowForNotOk(url, method, body?)` — sends request, throws on non-2xx; use for most mutations +- `getResponseJsonOrError(response)` + `throwResponseNotOkError(response, body)` — use together when you need custom status-code handling (e.g. treat 404 as empty result, not an error) - if (!response.ok) { - throw new Error('Failed to fetch'); - } - return response.json(); - }, - staleTime: 1000 * 60 * 5 // 5 minutes, data not expected to change frequently - }); -} +**Query key factories:** Define a key factory object alongside each query file for consistent cache management: -// In component -const { data, isLoading, error } = useMyData(fspName, filePath); +```typescript +export const myQueryKeys = { + all: ['myResource'] as const, + list: () => ['myResource', 'list'] as const, + detail: (id: string) => ['myResource', 'detail', id] as const +}; ``` -**Pattern for mutations:** +**Pattern for data fetching:** ```typescript -// In src/queries/useUpdateMyData.ts -import { useMutation, useQueryClient } from '@tanstack/react-query'; -import { buildApiUrl, sendFetchRequest } from '@/utils'; - -export function useUpdateMyData() { - const queryClient = useQueryClient(); - - return useMutation({ - mutationFn: async (payload: { fspName: string; data: MyData }) => { - // Use buildApiUrl for proper URL encoding - const url = buildApiUrl('/api/files/', [payload.fspName]); - - // Use sendFetchRequest - it handles headers, credentials, and error handling - const response = await sendFetchRequest(url, 'PUT', payload.data); - - if (!response.ok) { - throw new Error('Update failed'); - } - return response.json(); - }, - onSuccess: () => { - // Invalidate and refetch - queryClient.invalidateQueries({ queryKey: ['my-data'] }); - } +// In src/queries/myQueries.ts +import { useQuery } from '@tanstack/react-query'; +import type { UseQueryResult } from '@tanstack/react-query'; +import { buildUrl, sendFetchRequest } from '@/utils'; +import { getResponseJsonOrError, throwResponseNotOkError } from './queryUtils'; + +// Extract fetch logic outside the hook for reuse and testability +const fetchMyData = async ( + fspName: string, + path: string, + signal?: AbortSignal +): Promise => { + const url = buildUrl('/api/files/', fspName, { subpath: path }); + const response = await sendFetchRequest(url, 'GET', undefined, { signal }); + const body = await getResponseJsonOrError(response); + + if (response.status === 404) { + return null; // treat 404 as empty, not an error + } + if (!response.ok) { + throwResponseNotOkError(response, body); + } + return body as MyData; +}; + +export function useMyDataQuery( + fspName: string | undefined, + path: string | undefined +): UseQueryResult { + return useQuery({ + queryKey: myQueryKeys.detail(fspName ?? '', path ?? ''), + queryFn: ({ signal }) => fetchMyData(fspName!, path!, signal), + enabled: !!fspName && !!path, + staleTime: 5 * 60 * 1000, // override default only when there's a good reason + retry: false // omit to use the default; set false when retrying won't help }); } ``` -**Why use `sendFetchRequest`?** - -- Automatically includes credentials for session management -- Handles session expiration (401/403) with automatic logout -- Reports failed requests to health check monitoring -- Consistent error handling across the application -- Sets appropriate headers based on HTTP method - -### Routing - -- **Base path**: `/fg/` (configured in `vite.config.ts`) -- **Routes**: - - `/fg/` - Dashboard/Browse (default) - - `/fg/browse` - File browser - - `/fg/jobs` - Background jobs - - `/fg/links` - Data links management - - `/fg/help` - Help/support - - `/fg/preferences` - User preferences - - `/fg/notifications` - Notifications -- **Route definitions**: See `src/App.tsx` -- **Navigation**: Use React Router's `useNavigate()`, `Link`, or `NavLink` +**Mutations** use `useMutation` + `sendRequestAndThrowForNotOk`. Use `onMutate`/`onError` for optimistic updates with rollback, and `onSuccess` to call `queryClient.invalidateQueries` or `setQueryData`. See `proxiedPathQueries.ts` for a full example. ### Error Handling -- **React Error Boundary**: Wraps app to catch React errors -- **TanStack Query**: Built-in error handling for async operations - - Check `error` property from `useQuery`/`useMutation` -- **Toast notifications**: Use `react-hot-toast` for user-facing errors -- **Logging**: Use `logger` from `src/logger.ts` for debugging. Leave minimal loggers in final version of code - -### Testing Strategy - -**Unit Tests** (`src/__tests__/`) - -- **Setup**: `setup.ts` configures test environment (Happy DOM, React Testing Library) -- **Test utilities**: `test-utils.tsx` provides custom render functions with providers -- **API mocking**: MSW handlers in `mocks/handlers.ts` -- **Component tests**: `componentTests/` - test UI components in isolation -- **Unit tests**: `unitTests/` - test utility functions, helpers, hooks -- **Coverage**: Run with `npm test -- --coverage` - -**E2E Tests** (`ui-tests/`) - -- **Playwright** browser tests for full application flows -- Run from project root with `pixi run test-ui` - -**Testing best practices:** - -- Mock API calls with MSW handlers -- Use `screen.getByRole()` over `getByTestId()` -- Test user interactions, not implementation details -- Keep tests isolated and independent - -## Common Workflows - -### Adding a New Feature - -1. **Plan the feature** - Identify components, contexts, API calls needed -2. **Check for existing patterns** - Look for similar features to reuse/adapt -3. **Create components** - In appropriate `src/components/ui/` subdirectory -4. **Add state management** - Context for UI state and dependency injection, TanStack Query for server data -5. **Define types** - TypeScript interfaces in component files or `shared.types.ts` -6. **Add API integration** - Query/mutation hooks in `src/queries/` -7. **Update routing** - If needed, add route in `src/App.tsx` -8. **Add tests** - Unit tests in `__tests__/`, E2E tests in `ui-tests/` -9. **Update docs** - Document new patterns or conventions - -### Modifying Existing Components - -1. **Read the component** - Understand current implementation -2. **Check dependencies** - See what contexts/hooks it uses -3. **Update types** - Modify TypeScript interfaces as needed -4. **Make changes** - Follow existing patterns and conventions -5. **Update tests** - Ensure existing tests work with new behavior - -### Debugging - -**Development tools:** - -- **React DevTools** - Browser extension for component inspection -- **TanStack Query DevTools** - Automatically available in dev mode (bottom-left icon) -- **Browser DevTools** - Console, Network tab, React tab -- **Vite HMR** - Hot Module Replacement for fast feedback - -**Common issues:** - -- **Build errors**: Check TypeScript types, import paths -- **Runtime errors**: Check browser console, React Error Boundary -- **API errors**: Check TanStack Query DevTools, Network tab -- **State issues**: Check React Context providers, TanStack Query cache -- **Style issues**: Check Tailwind classes, `tailwind.config.js` - -### Working with Backend - -**API base URL:** - -- Development: `http://localhost:7878` (FastAPI backend) -- Production: Same origin as frontend (served by FastAPI) - -**API endpoints:** All under `/api/` prefix - -- `/api/files` - File operations -- `/api/proxied-paths` - Data links -- `/api/tickets` - Background jobs -- `/api/external-buckets` - S3 buckets -- `/api/file-share-paths` - File shares -- `/api/profile` - User profile -- `/api/health` - Backend health check - -**API integration:** - -- Use TanStack Query hooks in `src/queries/` -- Define query keys consistently for cache management -- Handle loading, error, and success states - -## Import Aliases - -- `@/` - Resolves to `./src/` (configured in `vite.config.ts` and `tsconfig.json`) -- Example: `import { logger } from '@/logger'` - -## Build Output - -- **Development build**: Outputs to `../fileglancer/ui/` -- **Served by**: FastAPI backend at `/fg/` path -- **Static assets**: `/fg/assets/` (Vite asset hashing applied) - -## Environment Variables - -- `.env` - Local environment configuration -- Variables must be prefixed with `VITE_` to be exposed to frontend -- Access via `import.meta.env.VITE_VAR_NAME` - -## Troubleshooting - -**Build issues:** - -- Clear cache: `rm -rf node_modules .eslintcache && npm install` or use `./clean.sh` from the root directory -- Check Node version: Should be v22.12+ -- Verify output directory: `../fileglancer/ui/` should exist after build - -**Import errors:** - -- Check import paths and file extensions (.tsx, .ts) -- Verify `@/` alias resolves correctly -- Check `tsconfig.json` paths configuration - -**Type errors:** - -- Run `pixi run node-eslint-check` to see all type errors -- Check TypeScript version matches project (5.8+) -- Verify all dependencies have type definitions - -**Vite/Rolldown issues:** - -- Check `vite.config.ts` for plugin configuration -- Clear Vite cache: `rm -rf node_modules/.vite` -- Note: Using Rolldown (Rust alternative to Rollup) via `rolldown-vite` package - -**Test failures:** - -- Check MSW handlers are properly configured -- Verify test setup in `src/__tests__/setup.ts` - -**TanStack Query issues:** - -- Check query keys are unique and consistent -- Use TanStack Query DevTools to inspect cache -- Verify `queryClient` configuration in `src/main.tsx` - -## Additional Resources - -- [React 18 Docs](https://react.dev) -- [TypeScript Handbook](https://www.typescriptlang.org/docs/) -- [Vite Guide](https://vite.dev/guide/) -- [React Router Docs](https://reactrouter.com) -- [TanStack Query Docs](https://tanstack.com/query/latest/docs/react/overview) -- [Material Tailwind Docs](https://www.material-tailwind.com) -- [Tailwind CSS Docs](https://tailwindcss.com/docs) -- [Vitest Docs](https://vitest.dev) -- [React Testing Library Docs](https://testing-library.com/react) -- [MSW Docs](https://mswjs.io) - ---- +- Either handle error or throw. Do not log and then throw. For backend development, database migrations, or full-stack workflows, look for a CLAUDE.md in the root directory. From 10ab39e0f37c13d314a9776531e62b4c8f6e54cc Mon Sep 17 00:00:00 2001 From: allison-truhlar Date: Fri, 6 Mar 2026 16:32:41 -0500 Subject: [PATCH 062/104] refactor: add getOmeNgffVersion, areZarrMetadataFilesPresent, and getEffectiveZarrStorageVersion utilities - also splits availableVersions in the ZarrMetadataResult type into availableZarrVersions (number[]) and availableOmeZarrVersions (string[]) --- frontend/src/queries/zarrQueries.ts | 67 +++++++++++++++++++++-------- 1 file changed, 50 insertions(+), 17 deletions(-) diff --git a/frontend/src/queries/zarrQueries.ts b/frontend/src/queries/zarrQueries.ts index ba8b8c406..33e25375e 100644 --- a/frontend/src/queries/zarrQueries.ts +++ b/frontend/src/queries/zarrQueries.ts @@ -1,4 +1,5 @@ -import { useQuery, UseQueryResult } from '@tanstack/react-query'; +import { useQuery } from '@tanstack/react-query'; +import type { UseQueryResult } from '@tanstack/react-query'; import { default as log } from '@/logger'; import { getOmeZarrMetadata, @@ -29,15 +30,18 @@ type ZarrMetadataQueryParams = { export type ZarrMetadataResult = { metadata: ZarrMetadata; omeZarrUrl: string | null; - availableVersions: ('v2' | 'v3')[]; + availableZarrVersions: number[]; + availableOmeZarrVersions: string[]; isOmeZarr: boolean; }; // Zarr v3 zarr.json structure type ZarrV3Attrs = { + zarr_format?: number; node_type: 'array' | 'group'; attributes?: { ome?: { + version?: string; multiscales?: unknown; labels?: string[]; }; @@ -51,28 +55,57 @@ type ZarrV2Attrs = { }; /** - * Detects which Zarr versions are supported by checking for version-specific marker files. - * @returns Array of supported versions: ['v2'], ['v3'], or ['v2', 'v3'] + * Extracts the OME-NGFF spec version from parsed metadata. + * Logic follows the OME-NGFF Validator. */ -export function detectZarrVersions(files: FileOrFolder[]): ('v2' | 'v3')[] { - if (!files || files.length === 0) { - return []; +export function getOmeNgffVersion(ngffData: Record): string { + let version: string | undefined; + + if (ngffData.attributes?.ome) { + version = ngffData.attributes.ome.version; + if (!version) { + log.warn('No version found in attributes.ome, defaulting to 0.4'); + } + // Used if 'attributes' is at the root + } else if (ngffData.ome?.version) { + version = ngffData.ome.version; + } else if (ngffData.version) { + version = ngffData.version; + } else { + // 0.4 and earlier: check multiscales, plate, or well + version = + ngffData.multiscales?.[0]?.version ?? + ngffData.plate?.version ?? + ngffData.well?.version; } - const hasFile = (name: string) => files.some(f => f.name === name); - const versions: ('v2' | 'v3')[] = []; + // for 0.4 and earlier, version wasn't MUST and we defaulted + // to using v0.4 for validation. To preserve that behaviour + // return "0.4" if no version found. + version = version || '0.4'; + // remove any -dev2 etc. + return version.split('-')[0]; +} - // Check for Zarr v2 indicators - if (hasFile('.zarray') || hasFile('.zattrs')) { - versions.push('v2'); +export function areZarrMetadataFilesPresent(files: FileOrFolder[]): boolean { + if (!files || files.length === 0) { + return false; } + const hasFile = (name: string) => files.some(f => f.name === name); + return hasFile('zarr.json') || hasFile('.zattrs') || hasFile('.zarray'); +} - // Check for Zarr v3 indicator - if (hasFile('zarr.json')) { - versions.push('v3'); +/** + * Returns the preferred Zarr storage version from available versions. + * Prefers v3 if available, otherwise v2. + */ +export function getEffectiveZarrStorageVersion( + availableZarrVersions: number[] +): 2 | 3 { + if (availableZarrVersions.includes(3)) { + return 3; } - - return versions; + return 2; } /** From 5b91435d4252ae47e8a006f7a273eb0f85a6a8be Mon Sep 17 00:00:00 2001 From: allison-truhlar Date: Fri, 6 Mar 2026 16:33:33 -0500 Subject: [PATCH 063/104] refactor: restructure fetchZarrMetadata to check all metadata sources and read versions from content --- frontend/src/queries/zarrQueries.ts | 239 ++++++++++++++++------------ 1 file changed, 134 insertions(+), 105 deletions(-) diff --git a/frontend/src/queries/zarrQueries.ts b/frontend/src/queries/zarrQueries.ts index 33e25375e..ad03b7a19 100644 --- a/frontend/src/queries/zarrQueries.ts +++ b/frontend/src/queries/zarrQueries.ts @@ -109,7 +109,9 @@ export function getEffectiveZarrStorageVersion( } /** - * Fetches Zarr metadata by checking for zarr.json, .zarray, or .zattrs files + * Fetches Zarr metadata by checking for zarr.json, .zattrs, and .zarray files. + * Always checks all metadata sources to build complete version arrays. + * Start with zarr.json for Zarr v3 metadata, then .zattrs for Zarr v2 metadata, then .zarray as fallback. */ async function fetchZarrMetadata({ fspName, @@ -121,7 +123,8 @@ async function fetchZarrMetadata({ return { metadata: null, omeZarrUrl: null, - availableVersions: [], + availableZarrVersions: [], + availableOmeZarrVersions: [], isOmeZarr: false }; } @@ -132,21 +135,44 @@ async function fetchZarrMetadata({ const getFile = (fileName: string) => files.find((file: FileOrFolder) => file.name === fileName); - const availableVersions = detectZarrVersions(files); + const availableZarrVersions: number[] = []; + const availableOmeZarrVersions: string[] = []; - // Default to Zarr v3 when available - if (availableVersions.includes('v3')) { - const zarrJsonFile = getFile('zarr.json') as FileOrFolder; + // Track whether we found primary metadata from zarr.json + let primaryMetadata: ZarrMetadataResult | null = null; + + // Step 1: Try zarr.json + const zarrJsonFile = getFile('zarr.json'); + if (zarrJsonFile) { const attrs = (await fetchFileAsJson( fspName, zarrJsonFile.path )) as ZarrV3Attrs; + // Read zarr_format field for Zarr storage version + const zarrStorageVersion = attrs.zarr_format; + if (zarrStorageVersion === undefined || zarrStorageVersion === null) { + log.warn('zarr.json missing zarr_format field, defaulting to 3'); + availableZarrVersions.push(3); + } else { + availableZarrVersions.push(zarrStorageVersion); + } + + const effectiveVersion: 2 | 3 = + zarrStorageVersion === 2 || zarrStorageVersion === 3 + ? zarrStorageVersion + : 3; + if (attrs.node_type === 'array') { - log.info('Getting Zarr array for', imageUrl, 'with Zarr version', 3); - const arr = await getZarrArray(imageUrl, 3); + log.info( + 'Getting Zarr array for', + imageUrl, + 'with Zarr version', + effectiveVersion + ); + const arr = await getZarrArray(imageUrl, effectiveVersion); const shapes = [arr.shape]; - return { + primaryMetadata = { metadata: { arr, shapes, @@ -154,19 +180,25 @@ async function fetchZarrMetadata({ scales: undefined, omero: undefined, labels: undefined, - zarrVersion: 3 + zarrVersion: effectiveVersion }, omeZarrUrl: null, - availableVersions, + availableZarrVersions, + availableOmeZarrVersions, isOmeZarr: false }; } else if (attrs.node_type === 'group') { if (attrs.attributes?.ome?.multiscales) { + const ngffVersion = getOmeNgffVersion(attrs); + if (!availableOmeZarrVersions.includes(ngffVersion)) { + availableOmeZarrVersions.push(ngffVersion); + } + log.info( 'Getting OME-Zarr metadata for', imageUrl, 'with Zarr version', - 3 + effectiveVersion ); const metadata = await getOmeZarrMetadata(imageUrl); // Check for labels @@ -182,119 +214,116 @@ async function fetchZarrMetadata({ } catch (error) { log.trace('Could not fetch labels attrs: ', error); } - return { + primaryMetadata = { metadata, omeZarrUrl: imageUrl, - availableVersions, + availableZarrVersions, + availableOmeZarrVersions, isOmeZarr: true }; } else { log.info('Zarrv3 group has no multiscales', attrs.attributes); - return { - metadata: null, - omeZarrUrl: null, - availableVersions, - isOmeZarr: false - }; + // Don't return yet - continue to check .zattrs } } else { log.warn('Unknown Zarrv3 node type', attrs.node_type); - return { - metadata: null, - omeZarrUrl: null, - availableVersions, - isOmeZarr: false - }; } - // v3 not available, now check for v2 - } else { - // v2 present - if (availableVersions.includes('v2')) { - const zarrayFile = getFile('.zarray'); - const zattrsFile = getFile('.zattrs'); - - // Check for .zarray (Zarr v2 array) - if (zarrayFile) { - log.info('Getting Zarr array for', imageUrl, 'with Zarr version', 2); - const arr = await getZarrArray(imageUrl, 2); - const shapes = [arr.shape]; - return { - metadata: { - arr, - shapes, - multiscale: undefined, - scales: undefined, - omero: undefined, - labels: undefined, - zarrVersion: 2 - }, - omeZarrUrl: null, - availableVersions, - isOmeZarr: false - }; - // Check for .zattrs (Zarr v2 OME-Zarr) - } else if (zattrsFile) { - const attrs = (await fetchFileAsJson( - fspName, - zattrsFile.path - )) as ZarrV2Attrs; - if (attrs.multiscales) { - log.info( - 'Getting OME-Zarr metadata for', - imageUrl, - 'with Zarr version', - 2 - ); - const metadata = await getOmeZarrMetadata(imageUrl); - // Check for labels - try { - const labelsAttrs = (await fetchFileAsJson( - fspName, - currentFileOrFolder.path + '/labels/.zattrs' - )) as ZarrV2Attrs; - metadata.labels = labelsAttrs?.labels; - if (metadata.labels) { - log.info('OME-Zarr Labels found: ', metadata.labels); - } - } catch (error) { - log.trace('Could not fetch labels attrs: ', error); + } + + // Step 2: Always also check .zattrs + const zattrsFile = getFile('.zattrs'); + if (zattrsFile) { + if (!availableZarrVersions.includes(2)) { + availableZarrVersions.push(2); + } + + const attrs = (await fetchFileAsJson( + fspName, + zattrsFile.path + )) as ZarrV2Attrs; + + if (attrs.multiscales) { + const ngffVersion = getOmeNgffVersion(attrs); + if (!availableOmeZarrVersions.includes(ngffVersion)) { + availableOmeZarrVersions.push(ngffVersion); + } + + // If we don't already have primary metadata from zarr.json, use .zattrs + if (!primaryMetadata) { + log.info( + 'Getting OME-Zarr metadata for', + imageUrl, + 'with Zarr version', + 2 + ); + const metadata = await getOmeZarrMetadata(imageUrl); + // Check for labels + try { + const labelsAttrs = (await fetchFileAsJson( + fspName, + currentFileOrFolder.path + '/labels/.zattrs' + )) as ZarrV2Attrs; + metadata.labels = labelsAttrs?.labels; + if (metadata.labels) { + log.info('OME-Zarr Labels found: ', metadata.labels); } - return { - metadata, - omeZarrUrl: imageUrl, - availableVersions, - isOmeZarr: true - }; - } else { - log.debug('Zarrv2 .zattrs has no multiscales', attrs); - return { - metadata: null, - omeZarrUrl: null, - availableVersions, - isOmeZarr: false - }; + } catch (error) { + log.trace('Could not fetch labels attrs: ', error); } - // No Zarr metadata found - } else { - log.debug('No Zarr metadata files found for', imageUrl); - return { - metadata: null, - omeZarrUrl: null, - availableVersions, - isOmeZarr: false + primaryMetadata = { + metadata, + omeZarrUrl: imageUrl, + availableZarrVersions, + availableOmeZarrVersions, + isOmeZarr: true }; } - // No Zarr metadata found } else { - log.debug('No supported Zarr versions detected for', imageUrl); + log.debug('Zarrv2 .zattrs has no multiscales', attrs); + } + } + + // Step 3: If neither zarr.json nor .zattrs had data, check .zarray + if (!primaryMetadata) { + const zarrayFile = getFile('.zarray'); + if (zarrayFile) { + if (!availableZarrVersions.includes(2)) { + availableZarrVersions.push(2); + } + log.info('Getting Zarr array for', imageUrl, 'with Zarr version', 2); + const arr = await getZarrArray(imageUrl, 2); + const shapes = [arr.shape]; return { - metadata: null, + metadata: { + arr, + shapes, + multiscale: undefined, + scales: undefined, + omero: undefined, + labels: undefined, + zarrVersion: 2 + }, omeZarrUrl: null, - availableVersions: [], + availableZarrVersions, + availableOmeZarrVersions, isOmeZarr: false }; } } + + // Return primary metadata if found, otherwise return empty result + if (primaryMetadata) { + return primaryMetadata; + } + + log.debug('No Zarr metadata found for', imageUrl); + return { + metadata: null, + omeZarrUrl: null, + availableZarrVersions, + availableOmeZarrVersions, + isOmeZarr: false + }; } /** @@ -318,7 +347,7 @@ export function useZarrMetadataQuery( !!currentFileOrFolder && !!files && files.length > 0 && - detectZarrVersions(files).length > 0, + areZarrMetadataFilesPresent(files), staleTime: 5 * 60 * 1000, // 5 minutes - Zarr metadata doesn't change often retry: false // Don't retry if no Zarr files found }); From da940a1667c52eb939750ab56e324fcd9e78b2f6 Mon Sep 17 00:00:00 2001 From: allison-truhlar Date: Fri, 6 Mar 2026 16:33:48 -0500 Subject: [PATCH 064/104] refactor: use real NGFF version and getEffectiveZarrStorageVersion in useZarrMetadata --- frontend/src/hooks/useZarrMetadata.ts | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/frontend/src/hooks/useZarrMetadata.ts b/frontend/src/hooks/useZarrMetadata.ts index 283e40740..22b26a252 100644 --- a/frontend/src/hooks/useZarrMetadata.ts +++ b/frontend/src/hooks/useZarrMetadata.ts @@ -7,7 +7,8 @@ import { useExternalBucketContext } from '@/contexts/ExternalBucketContext'; import { useViewersContext } from '@/contexts/ViewersContext'; import { useZarrMetadataQuery, - useOmeZarrThumbnailQuery + useOmeZarrThumbnailQuery, + getEffectiveZarrStorageVersion } from '@/queries/zarrQueries'; import type { OpenWithToolUrls, ZarrMetadata } from '@/queries/zarrQueries'; import { @@ -45,8 +46,9 @@ export default function useZarrMetadata() { files: fileQuery.data?.files }); - const effectiveZarrVersion = - zarrMetadataQuery.data?.availableVersions.includes('v3') ? 3 : 2; + const effectiveZarrVersion = getEffectiveZarrStorageVersion( + zarrMetadataQuery.data?.availableZarrVersions ?? [] + ); const metadata = zarrMetadataQuery.data?.metadata || null; const omeZarrUrl = zarrMetadataQuery.data?.omeZarrUrl || null; @@ -112,7 +114,9 @@ export default function useZarrMetadata() { if (metadata?.multiscale) { // Convert our metadata to OmeZarrMetadata format for capability checking const omeZarrMetadata = { - version: effectiveZarrVersion === 3 ? '0.5' : '0.4', + version: zarrMetadataQuery.data?.availableOmeZarrVersions.sort( + (a, b) => parseFloat(b) - parseFloat(a) + )[0], axes: metadata.multiscale?.axes, multiscales: metadata.multiscale ? [metadata.multiscale] : undefined, omero: metadata.omero, @@ -235,6 +239,7 @@ export default function useZarrMetadata() { useLegacyMultichannelApproach, layerType, effectiveZarrVersion, + zarrMetadataQuery.data?.availableOmeZarrVersions, validViewers, viewersInitialized, getCompatibleViewers @@ -245,6 +250,6 @@ export default function useZarrMetadata() { thumbnailQuery, openWithToolUrls, layerType, - availableVersions: zarrMetadataQuery.data?.availableVersions || [] + availableZarrVersions: zarrMetadataQuery.data?.availableZarrVersions || [] }; } From 13148214595686a82ba6a5ea766fb610600a13fe Mon Sep 17 00:00:00 2001 From: allison-truhlar Date: Fri, 6 Mar 2026 16:34:17 -0500 Subject: [PATCH 065/104] refactor: update components for new zarr/OME-NGFF version API --- .../components/ui/BrowsePage/FileBrowser.tsx | 11 ++++++----- .../ui/BrowsePage/ZarrMetadataTable.tsx | 8 ++++---- .../components/ui/BrowsePage/ZarrPreview.tsx | 6 +++--- .../dataLinkUsage/DataLinkUsageDialog.tsx | 17 +++++++++-------- 4 files changed, 22 insertions(+), 20 deletions(-) diff --git a/frontend/src/components/ui/BrowsePage/FileBrowser.tsx b/frontend/src/components/ui/BrowsePage/FileBrowser.tsx index d99d020e3..54fd1ccc3 100644 --- a/frontend/src/components/ui/BrowsePage/FileBrowser.tsx +++ b/frontend/src/components/ui/BrowsePage/FileBrowser.tsx @@ -19,7 +19,7 @@ import { useFileBrowserContext } from '@/contexts/FileBrowserContext'; import { usePreferencesContext } from '@/contexts/PreferencesContext'; import useHideDotFiles from '@/hooks/useHideDotFiles'; import { useHandleDownload } from '@/hooks/useHandleDownload'; -import { detectZarrVersions } from '@/queries/zarrQueries'; +import { areZarrMetadataFilesPresent } from '@/queries/zarrQueries'; import { detectN5 } from '@/queries/n5Queries'; import { makeMapKey } from '@/utils'; import type { FileOrFolder } from '@/shared.types'; @@ -74,14 +74,15 @@ export default function FileBrowser({ thumbnailQuery, openWithToolUrls, layerType, - availableVersions + availableZarrVersions } = useZarrMetadata(); const { n5MetadataQuery, openWithToolUrls: n5OpenWithToolUrls } = useN5Metadata(); - const isZarrDir = - detectZarrVersions(fileQuery.data?.files as FileOrFolder[]).length > 0; + const isZarrDir = areZarrMetadataFilesPresent( + fileQuery.data?.files as FileOrFolder[] + ); const isN5Dir = detectN5(fileQuery.data?.files as FileOrFolder[]); @@ -205,7 +206,7 @@ export default function FileBrowser({ ) : zarrMetadataQuery.data?.metadata ? ( Zarr Version - {availableVersions && availableVersions.length > 1 - ? availableVersions.join(', ') + {availableZarrVersions && availableZarrVersions.length > 0 + ? availableZarrVersions.join(', ') : zarrVersion} diff --git a/frontend/src/components/ui/BrowsePage/ZarrPreview.tsx b/frontend/src/components/ui/BrowsePage/ZarrPreview.tsx index eac0a69fb..fac230b76 100644 --- a/frontend/src/components/ui/BrowsePage/ZarrPreview.tsx +++ b/frontend/src/components/ui/BrowsePage/ZarrPreview.tsx @@ -20,7 +20,7 @@ type ZarrPreviewProps = { readonly mainPanelWidth: number; readonly openWithToolUrls: OpenWithToolUrls | null; readonly path: string; - readonly availableVersions: ('v2' | 'v3')[]; + readonly availableZarrVersions: number[]; readonly thumbnailQuery: UseQueryResult; readonly zarrMetadataQuery: UseQueryResult<{ metadata: ZarrMetadata; @@ -29,7 +29,7 @@ type ZarrPreviewProps = { }; export default function ZarrPreview({ - availableVersions, + availableZarrVersions, fspName, layerType, mainPanelWidth, @@ -125,7 +125,7 @@ export default function ZarrPreview({ className={`flex ${mainPanelWidth > 1000 ? 'gap-6' : 'flex-col gap-4'} h-fit`} > diff --git a/frontend/src/components/ui/Dialogs/dataLinkUsage/DataLinkUsageDialog.tsx b/frontend/src/components/ui/Dialogs/dataLinkUsage/DataLinkUsageDialog.tsx index 2e704529a..70802641a 100644 --- a/frontend/src/components/ui/Dialogs/dataLinkUsage/DataLinkUsageDialog.tsx +++ b/frontend/src/components/ui/Dialogs/dataLinkUsage/DataLinkUsageDialog.tsx @@ -7,7 +7,8 @@ import DataLinkTabs from '@/components/ui/Dialogs/dataLinkUsage/tabsContent/Data import CopyTooltip from '@/components/ui/widgets/CopyTooltip'; import useFileQuery from '@/queries/fileQueries'; import { - detectZarrVersions, + areZarrMetadataFilesPresent, + getEffectiveZarrStorageVersion, useZarrMetadataQuery } from '@/queries/zarrQueries'; import { detectN5 } from '@/queries/n5Queries'; @@ -36,8 +37,7 @@ export default function DataLinkUsageDialog({ const targetFileQuery = useFileQuery(fspName, path); const files = targetFileQuery.data?.files ?? []; - const zarrVersions = detectZarrVersions(files); - const isZarr = zarrVersions.length > 0; + const isZarr = areZarrMetadataFilesPresent(files); const isN5 = detectN5(files); // Reuse the zarr metadata query — TanStack Query caches by key, @@ -48,11 +48,12 @@ export default function DataLinkUsageDialog({ files }); - const zarrVersion: ZarrVersion | undefined = isZarr - ? zarrVersions.includes('v3') - ? 3 - : 2 - : undefined; + const zarrVersion: ZarrVersion | undefined = + isZarr && zarrMetadataQuery.data + ? getEffectiveZarrStorageVersion( + zarrMetadataQuery.data.availableZarrVersions + ) + : undefined; // Determine data type: for zarr, wait for metadata query to distinguish OME vs plain let dataType: DataLinkType; From 08a6691ec5016c03db1c073fa1255d2c0fb9b8d5 Mon Sep 17 00:00:00 2001 From: allison-truhlar Date: Fri, 6 Mar 2026 16:34:29 -0500 Subject: [PATCH 066/104] test: update zarr version detection and metadata table tests --- .../componentTests/ZarrMetadataTable.test.tsx | 10 +- .../unitTests/zarrVersionDetection.test.ts | 107 +++++++++++++++--- 2 files changed, 95 insertions(+), 22 deletions(-) diff --git a/frontend/src/__tests__/componentTests/ZarrMetadataTable.test.tsx b/frontend/src/__tests__/componentTests/ZarrMetadataTable.test.tsx index 4a5791843..f78f8d759 100644 --- a/frontend/src/__tests__/componentTests/ZarrMetadataTable.test.tsx +++ b/frontend/src/__tests__/componentTests/ZarrMetadataTable.test.tsx @@ -26,7 +26,8 @@ vi.mock('@/hooks/useZarrMetadata', async () => { // Test component that uses the actual useZarrMetadata hook function ZarrMetadataTableTestWrapper() { - const { availableVersions, layerType, zarrMetadataQuery } = useZarrMetadata(); + const { availableZarrVersions, layerType, zarrMetadataQuery } = + useZarrMetadata(); // Don't render until we have metadata if (!zarrMetadataQuery.data?.metadata) { @@ -35,7 +36,7 @@ function ZarrMetadataTableTestWrapper() { return ( @@ -43,14 +44,15 @@ function ZarrMetadataTableTestWrapper() { } describe('ZarrMetadataTable', () => { - it('should display "v2, v3" when both versions are available', async () => { + it('should display "3, 2" when both zarr versions are available', async () => { render(, { initialEntries: ['/browse/test_fsp/my_folder/ome_zarr_both_versions'] }); // Wait for the metadata table to render with version info + // zarr.json (v3) is checked first, then .zattrs (v2), so order is 3, 2 await waitFor(() => { - expect(screen.getByText('v2, v3')).toBeInTheDocument(); + expect(screen.getByText('3, 2')).toBeInTheDocument(); }); }); diff --git a/frontend/src/__tests__/unitTests/zarrVersionDetection.test.ts b/frontend/src/__tests__/unitTests/zarrVersionDetection.test.ts index 1468039b1..e032ab087 100644 --- a/frontend/src/__tests__/unitTests/zarrVersionDetection.test.ts +++ b/frontend/src/__tests__/unitTests/zarrVersionDetection.test.ts @@ -1,5 +1,9 @@ import { describe, it, expect } from 'vitest'; -import { detectZarrVersions } from '@/queries/zarrQueries'; +import { + areZarrMetadataFilesPresent, + getOmeNgffVersion, + getEffectiveZarrStorageVersion +} from '@/queries/zarrQueries'; import { FileOrFolder } from '@/shared.types'; // Helper to create minimal FileOrFolder objects for testing @@ -14,36 +18,103 @@ const createFile = (name: string): FileOrFolder => ({ last_modified: Date.now() }); -describe('detectZarrVersions', () => { - it('should detect only zarr v3 when only zarr.json exists', () => { - const files = [ - createFile('zarr.json'), - createFile('arrays/data/chunk_key_1') - ]; - const result = detectZarrVersions(files); - expect(result).toEqual(['v3']); +describe('areZarrMetadataFilesPresent', () => { + it('should return true when zarr.json exists', () => { + const files = [createFile('zarr.json'), createFile('arrays/data/chunk_key_1')]; + expect(areZarrMetadataFilesPresent(files)).toBe(true); }); - it('should detect only zarr v2 when only .zarray exists', () => { + it('should return true when .zarray exists', () => { const files = [createFile('.zarray'), createFile('.zattrs')]; - const result = detectZarrVersions(files); - expect(result).toEqual(['v2']); + expect(areZarrMetadataFilesPresent(files)).toBe(true); }); - it('should detect both versions when both zarr.json and .zarray exist', () => { + it('should return true when .zattrs exists', () => { + const files = [createFile('.zattrs')]; + expect(areZarrMetadataFilesPresent(files)).toBe(true); + }); + + it('should return true when both zarr.json and .zarray exist', () => { const files = [ createFile('zarr.json'), createFile('.zarray'), createFile('.zattrs'), createFile('arrays/data/chunk_key_1') ]; - const result = detectZarrVersions(files); - expect(result).toEqual(['v2', 'v3']); + expect(areZarrMetadataFilesPresent(files)).toBe(true); }); - it('should return empty array when neither version files exist', () => { + it('should return false when no zarr metadata files exist', () => { const files = [createFile('file.txt'), createFile('other.json')]; - const result = detectZarrVersions(files); - expect(result).toEqual([]); + expect(areZarrMetadataFilesPresent(files)).toBe(false); + }); + + it('should return false for empty file list', () => { + expect(areZarrMetadataFilesPresent([])).toBe(false); + }); +}); + +describe('getOmeNgffVersion', () => { + it('should return version from attributes.ome.version', () => { + const data = { attributes: { ome: { version: '0.5' } } }; + expect(getOmeNgffVersion(data)).toBe('0.5'); + }); + + it('should return version from ome.version', () => { + const data = { ome: { version: '0.5' } }; + expect(getOmeNgffVersion(data)).toBe('0.5'); + }); + + it('should return version from top-level version', () => { + const data = { version: '0.3' }; + expect(getOmeNgffVersion(data)).toBe('0.3'); + }); + + it('should return version from multiscales[0].version', () => { + const data = { multiscales: [{ version: '0.4' }] }; + expect(getOmeNgffVersion(data)).toBe('0.4'); + }); + + it('should return version from plate.version', () => { + const data = { plate: { version: '0.4' } }; + expect(getOmeNgffVersion(data)).toBe('0.4'); + }); + + it('should return version from well.version', () => { + const data = { well: { version: '0.4' } }; + expect(getOmeNgffVersion(data)).toBe('0.4'); + }); + + it('should return 0.4 when no version is found anywhere', () => { + const data = { someOtherField: 'value' }; + expect(getOmeNgffVersion(data)).toBe('0.4'); + }); + + it('should strip pre-release suffix from version', () => { + const data = { attributes: { ome: { version: '0.5-dev2' } } }; + expect(getOmeNgffVersion(data)).toBe('0.5'); + }); + + it('should return 0.4 when attributes.ome exists but has no version', () => { + const data = { attributes: { ome: { multiscales: [] } } }; + expect(getOmeNgffVersion(data)).toBe('0.4'); + }); +}); + +describe('getEffectiveZarrStorageVersion', () => { + it('should return 3 when only v3 is available', () => { + expect(getEffectiveZarrStorageVersion([3])).toBe(3); + }); + + it('should return 2 when only v2 is available', () => { + expect(getEffectiveZarrStorageVersion([2])).toBe(2); + }); + + it('should prefer v3 when both v2 and v3 are available', () => { + expect(getEffectiveZarrStorageVersion([2, 3])).toBe(3); + }); + + it('should return 2 when no versions are available', () => { + expect(getEffectiveZarrStorageVersion([])).toBe(2); }); }); From 45cfc05ee34d73f29896185b55f79767b4af83ed Mon Sep 17 00:00:00 2001 From: allison-truhlar Date: Fri, 6 Mar 2026 17:02:31 -0500 Subject: [PATCH 067/104] tests: fix DataToolLinks tests - copy icon had changed from img to svg - i saved the vizarr/avivator logo under avivator.png, so needed to update vizarr -> avivator --- .../componentTests/DataToolLinks.test.tsx | 53 +++++++------------ 1 file changed, 20 insertions(+), 33 deletions(-) diff --git a/frontend/src/__tests__/componentTests/DataToolLinks.test.tsx b/frontend/src/__tests__/componentTests/DataToolLinks.test.tsx index 276eae05f..2882a5b0f 100644 --- a/frontend/src/__tests__/componentTests/DataToolLinks.test.tsx +++ b/frontend/src/__tests__/componentTests/DataToolLinks.test.tsx @@ -26,10 +26,8 @@ vi.mock('@bioimagetools/capability-manifest', () => mockCapabilityManifest); const mockOpenWithToolUrls: OpenWithToolUrls = { copy: 'http://localhost:3000/test/copy/url', - validator: 'http://localhost:3000/test/validator/url', neuroglancer: 'http://localhost:3000/test/neuroglancer/url', - vole: 'http://localhost:3000/test/vole/url', - vizarr: 'http://localhost:3000/test/vizarr/url' + avivator: 'http://localhost:3000/test/avivator/url' }; // Helper component to wrap DataToolLinks with ViewersProvider @@ -220,21 +218,16 @@ describe('DataToolLinks - Edge Cases', () => { { timeout: 3000 } ); - // Should have neuroglancer logo and copy icon + // Should have neuroglancer logo const images = screen.getAllByRole('img'); - expect(images.length).toBeGreaterThanOrEqual(2); - - // Check for neuroglancer logo const neuroglancerLogo = images.find( img => img.getAttribute('alt') === 'View in Neuroglancer' ); expect(neuroglancerLogo).toBeTruthy(); - // Check for copy icon - const copyIcon = images.find( - img => img.getAttribute('alt') === 'Copy URL icon' - ); - expect(copyIcon).toBeTruthy(); + // Copy button is an SVG icon, find by aria-label + const copyButton = screen.getByLabelText('Copy data URL'); + expect(copyButton).toBeInTheDocument(); }); }); @@ -299,15 +292,13 @@ describe('DataToolLinks - Expected Behavior', () => { { timeout: 3000 } ); - // Should render copy icon at minimum + // Should render viewer logos const images = screen.getAllByRole('img'); - const copyIcon = images.find( - img => img.getAttribute('alt') === 'Copy URL icon' - ); - expect(copyIcon).toBeTruthy(); + expect(images.length).toBeGreaterThanOrEqual(1); - // Should also have viewer logos - expect(images.length).toBeGreaterThan(1); + // Copy button is an SVG icon, find by aria-label + const copyButton = screen.getByLabelText('Copy data URL'); + expect(copyButton).toBeInTheDocument(); }); it('should call onToolClick when copy icon is clicked', async () => { @@ -321,17 +312,11 @@ describe('DataToolLinks - Expected Behavior', () => { { timeout: 3000 } ); - // Click the copy icon (always present) - const images = screen.getAllByRole('img'); - const copyIcon = images.find( - img => img.getAttribute('alt') === 'Copy URL icon' - ); - expect(copyIcon).toBeTruthy(); - - const copyButton = copyIcon!.closest('button'); - expect(copyButton).toBeTruthy(); + // Click the copy button (SVG icon, not img — find by aria-label) + const copyButton = screen.getByLabelText('Copy data URL'); + expect(copyButton).toBeInTheDocument(); - copyButton!.click(); + copyButton.click(); await waitFor(() => { expect(onToolClick).toHaveBeenCalledWith('copy'); @@ -348,12 +333,10 @@ describe('DataToolLinks - Expected Behavior', () => { { timeout: 3000 } ); + // Verify viewer logos are present (img elements) const images = screen.getAllByRole('img'); + expect(images.length).toBeGreaterThanOrEqual(2); - // Should have neuroglancer, vizarr, and copy icons at minimum - expect(images.length).toBeGreaterThanOrEqual(3); - - // Verify specific logos are present const neuroglancerLogo = images.find( img => img.getAttribute('alt') === 'View in Neuroglancer' ); @@ -361,6 +344,10 @@ describe('DataToolLinks - Expected Behavior', () => { img => img.getAttribute('alt') === 'View in Avivator' ); + // Copy icon is an SVG, not an img — verify separately + const copyButton = screen.getByLabelText('Copy data URL'); + expect(copyButton).toBeInTheDocument(); + expect(neuroglancerLogo).toBeTruthy(); expect(vizarrLogo).toBeTruthy(); }); From 13642a793653a1962b7f76ebd00afc0b47890aa1 Mon Sep 17 00:00:00 2001 From: allison-truhlar Date: Fri, 6 Mar 2026 17:02:53 -0500 Subject: [PATCH 068/104] chore: move N5MetadataTable test to _tests_ directory --- .../componentTests}/N5MetadataTable.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename frontend/src/{components/ui/BrowsePage => __tests__/componentTests}/N5MetadataTable.test.tsx (98%) diff --git a/frontend/src/components/ui/BrowsePage/N5MetadataTable.test.tsx b/frontend/src/__tests__/componentTests/N5MetadataTable.test.tsx similarity index 98% rename from frontend/src/components/ui/BrowsePage/N5MetadataTable.test.tsx rename to frontend/src/__tests__/componentTests/N5MetadataTable.test.tsx index f3dbb39d8..982eee2d8 100644 --- a/frontend/src/components/ui/BrowsePage/N5MetadataTable.test.tsx +++ b/frontend/src/__tests__/componentTests/N5MetadataTable.test.tsx @@ -1,6 +1,6 @@ import { render, screen } from '@testing-library/react'; import { describe, it, expect } from 'vitest'; -import N5MetadataTable from './N5MetadataTable'; +import N5MetadataTable from '../../components/ui/BrowsePage/N5MetadataTable'; import type { N5Metadata } from '@/queries/n5Queries'; const mockS0Attrs = { From 032708aa7c4a2e91c0ab9e697f3b4843e9d5d07c Mon Sep 17 00:00:00 2001 From: allison-truhlar Date: Fri, 6 Mar 2026 17:03:33 -0500 Subject: [PATCH 069/104] chore: prettier formatting --- frontend/CLAUDE.md | 9 +++++---- .../src/__tests__/unitTests/zarrVersionDetection.test.ts | 5 ++++- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/frontend/CLAUDE.md b/frontend/CLAUDE.md index 84f820c7d..aa0b85e80 100644 --- a/frontend/CLAUDE.md +++ b/frontend/CLAUDE.md @@ -13,17 +13,16 @@ Use separate import lines for values and types from the same package (e.g. `impo Never manually construct URLs with template strings. Use utility functions (`src/utils/index.ts`, `src/utils/pathHandling.ts`): 1. **`buildUrl`** (`src/utils/index.ts`) - General-purpose URL builder with two overloads: - - **Overload 1**: `buildUrl(baseUrl, singlePathSegment | null, queryParams | null)` — single path segment encoded with `encodeURIComponent`, plus optional query params via `URLSearchParams` ```typescript - buildUrl('/api/files/', 'myFSP', { subpath: 'folder/file.txt' }) + buildUrl('/api/files/', 'myFSP', { subpath: 'folder/file.txt' }); // → '/api/files/myFSP?subpath=folder%2Ffile.txt' - buildUrl('/api/endpoint', null, { key: 'value' }) + buildUrl('/api/endpoint', null, { key: 'value' }); // → '/api/endpoint?key=value' ``` - **Overload 2**: `buildUrl(baseUrl, multiSegmentPathString)` — multi-segment path encoded with `escapePathForUrl` (preserves `/`) ```typescript - buildUrl('https://s3.example.com/bucket', 'folder/file 100%.zarr') + buildUrl('https://s3.example.com/bucket', 'folder/file 100%.zarr'); // → 'https://s3.example.com/bucket/folder/file%20100%25.zarr' ``` - **Use for**: All internal API calls and any external URL that needs path or query encoding @@ -54,10 +53,12 @@ Never manually construct URLs with template strings. Use utility functions (`src ### API Integration with TanStack Query **URL construction:** Use `buildUrl` from `@/utils` for all API URLs: + - `buildUrl('/api/files/', fspName, { subpath: path })` — path segment + query params - `buildUrl('/api/resource/', id, null)` — path segment only **Query utilities** (`src/queries/queryUtils.ts`): + - `sendRequestAndThrowForNotOk(url, method, body?)` — sends request, throws on non-2xx; use for most mutations - `getResponseJsonOrError(response)` + `throwResponseNotOkError(response, body)` — use together when you need custom status-code handling (e.g. treat 404 as empty result, not an error) diff --git a/frontend/src/__tests__/unitTests/zarrVersionDetection.test.ts b/frontend/src/__tests__/unitTests/zarrVersionDetection.test.ts index e032ab087..5918917ba 100644 --- a/frontend/src/__tests__/unitTests/zarrVersionDetection.test.ts +++ b/frontend/src/__tests__/unitTests/zarrVersionDetection.test.ts @@ -20,7 +20,10 @@ const createFile = (name: string): FileOrFolder => ({ describe('areZarrMetadataFilesPresent', () => { it('should return true when zarr.json exists', () => { - const files = [createFile('zarr.json'), createFile('arrays/data/chunk_key_1')]; + const files = [ + createFile('zarr.json'), + createFile('arrays/data/chunk_key_1') + ]; expect(areZarrMetadataFilesPresent(files)).toBe(true); }); From 7c1c2914cece0b554e440fb961d004de6cbf0f5a Mon Sep 17 00:00:00 2001 From: allison-truhlar Date: Mon, 9 Mar 2026 11:56:10 +0000 Subject: [PATCH 070/104] feat: log viewer incompatibility reasons to browser console Use validateViewer() instead of isCompatible() so that when a viewer is filtered out, the specific reasons are logged (e.g. "Viewer does not support OME-Zarr v3 (supports: 0.4)"). --- .../componentTests/DataToolLinks.test.tsx | 24 ++++++++++++++----- frontend/src/contexts/ViewersContext.tsx | 19 +++++++++++---- 2 files changed, 33 insertions(+), 10 deletions(-) diff --git a/frontend/src/__tests__/componentTests/DataToolLinks.test.tsx b/frontend/src/__tests__/componentTests/DataToolLinks.test.tsx index 2882a5b0f..7bc370114 100644 --- a/frontend/src/__tests__/componentTests/DataToolLinks.test.tsx +++ b/frontend/src/__tests__/componentTests/DataToolLinks.test.tsx @@ -19,7 +19,7 @@ vi.mock('@/logger', () => ({ // Mock capability manifest to avoid network requests in tests const mockCapabilityManifest = vi.hoisted(() => ({ loadManifestsFromUrls: vi.fn(), - isCompatible: vi.fn() + validateViewer: vi.fn() })); vi.mock('@bioimagetools/capability-manifest', () => mockCapabilityManifest); @@ -67,7 +67,11 @@ describe('DataToolLinks - Error Scenarios', () => { // Default mock: return empty Map (no manifests loaded) mockCapabilityManifest.loadManifestsFromUrls.mockResolvedValue(new Map()); - mockCapabilityManifest.isCompatible.mockReturnValue(false); + mockCapabilityManifest.validateViewer.mockReturnValue({ + compatible: false, + errors: [{ capability: 'test', message: 'Not compatible', required: null, found: null }], + warnings: [] + }); }); describe('Invalid YAML syntax', () => { @@ -166,8 +170,12 @@ describe('DataToolLinks - Edge Cases', () => { } ); - // Mock isCompatible to return true for all viewers - mockCapabilityManifest.isCompatible.mockReturnValue(true); + // Mock validateViewer to return compatible for all viewers + mockCapabilityManifest.validateViewer.mockReturnValue({ + compatible: true, + errors: [], + warnings: [] + }); }); describe('Logo rendering in components', () => { @@ -277,8 +285,12 @@ describe('DataToolLinks - Expected Behavior', () => { } ); - // Mock isCompatible to return true for all viewers - mockCapabilityManifest.isCompatible.mockReturnValue(true); + // Mock validateViewer to return compatible for all viewers + mockCapabilityManifest.validateViewer.mockReturnValue({ + compatible: true, + errors: [], + warnings: [] + }); }); describe('Component behavior with valid viewers', () => { diff --git a/frontend/src/contexts/ViewersContext.tsx b/frontend/src/contexts/ViewersContext.tsx index 0570d840e..2fc54eb18 100644 --- a/frontend/src/contexts/ViewersContext.tsx +++ b/frontend/src/contexts/ViewersContext.tsx @@ -8,7 +8,7 @@ import { } from 'react'; import { loadManifestsFromUrls, - isCompatible, + validateViewer, type ViewerManifest, type OmeZarrMetadata } from '@bioimagetools/capability-manifest'; @@ -194,9 +194,20 @@ export function ViewersProvider({ return []; } - return validViewers.filter(viewer => - isCompatible(viewer.manifest, metadata) - ); + return validViewers.filter(viewer => { + const result = validateViewer(viewer.manifest, metadata); + if (!result.compatible) { + log.info( + `Viewer "${viewer.displayName}" is not compatible with this dataset: ${result.errors.map(e => e.message).join('; ')}` + ); + } + if (result.warnings.length > 0) { + log.info( + `Viewer "${viewer.displayName}" warnings: ${result.warnings.map(w => w.message).join('; ')}` + ); + } + return result.compatible; + }); }, [validViewers, isInitialized] ); From 521018122d84ff5e0246cc54252164bfe38db9ca Mon Sep 17 00:00:00 2001 From: allison-truhlar Date: Mon, 9 Mar 2026 08:02:45 -0400 Subject: [PATCH 071/104] chore: prettier formatting --- .../src/__tests__/componentTests/DataToolLinks.test.tsx | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/frontend/src/__tests__/componentTests/DataToolLinks.test.tsx b/frontend/src/__tests__/componentTests/DataToolLinks.test.tsx index 7bc370114..35c154164 100644 --- a/frontend/src/__tests__/componentTests/DataToolLinks.test.tsx +++ b/frontend/src/__tests__/componentTests/DataToolLinks.test.tsx @@ -69,7 +69,14 @@ describe('DataToolLinks - Error Scenarios', () => { mockCapabilityManifest.loadManifestsFromUrls.mockResolvedValue(new Map()); mockCapabilityManifest.validateViewer.mockReturnValue({ compatible: false, - errors: [{ capability: 'test', message: 'Not compatible', required: null, found: null }], + errors: [ + { + capability: 'test', + message: 'Not compatible', + required: null, + found: null + } + ], warnings: [] }); }); From 44821b992e3fb56eee457f0dba8acf8e369979d8 Mon Sep 17 00:00:00 2001 From: allison-truhlar Date: Mon, 9 Mar 2026 08:08:36 -0400 Subject: [PATCH 072/104] docs: update viewer compatibility flow in ViewersConfiguration.md --- docs/ViewersConfiguration.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/ViewersConfiguration.md b/docs/ViewersConfiguration.md index bd91a57eb..554db0c51 100644 --- a/docs/ViewersConfiguration.md +++ b/docs/ViewersConfiguration.md @@ -204,8 +204,9 @@ viewers: The `@bioimagetools/capability-manifest` library handles all compatibility checking. When a user views an OME-Zarr dataset: 1. The application reads the dataset's metadata (OME-Zarr version, axes, codecs, etc.) -2. For each registered viewer, the library's `isCompatible()` function compares the dataset metadata against the manifest's declared capabilities +2. For each registered viewer, the library's `validateViewer()` function compares the dataset metadata against the manifest's declared capabilities 3. Only viewers whose capabilities match the dataset are shown to the user +4. Incompatibility reasons (e.g., "Viewer does not support OME-Zarr v3") are logged to the browser console for debugging This replaces the previous system where `valid_ome_zarr_versions` was a global config setting and custom viewers used simple version matching. Now all compatibility logic is driven by the detailed capabilities declared in each viewer's manifest. From 6c60026e5a661b4183291344ab9928aae5d98414 Mon Sep 17 00:00:00 2001 From: allison-truhlar Date: Fri, 13 Mar 2026 09:05:16 -0400 Subject: [PATCH 073/104] tests, chore: set global expect timeout --- frontend/ui-tests/playwright.config.js | 3 ++ .../tests/data-link-operations.spec.ts | 36 +++++++++---------- frontend/ui-tests/utils/navigation.ts | 2 +- 3 files changed, 21 insertions(+), 20 deletions(-) diff --git a/frontend/ui-tests/playwright.config.js b/frontend/ui-tests/playwright.config.js index 412788710..260d78734 100644 --- a/frontend/ui-tests/playwright.config.js +++ b/frontend/ui-tests/playwright.config.js @@ -36,6 +36,9 @@ export default defineConfig({ }, timeout: process.env.CI ? 180_000 : 20_000, navigationTimeout: process.env.CI ? 90_000 : 10_000, + expect: { + timeout: 20_000 + }, workers: 1, webServer: { command: 'pixi run test-launch', diff --git a/frontend/ui-tests/tests/data-link-operations.spec.ts b/frontend/ui-tests/tests/data-link-operations.spec.ts index e4b33ea86..8712fc9f1 100644 --- a/frontend/ui-tests/tests/data-link-operations.spec.ts +++ b/frontend/ui-tests/tests/data-link-operations.spec.ts @@ -18,7 +18,7 @@ const navigateToZarrDir = async ( await navigateToTestDir(page, fullTestPath); await page.getByRole('link', { name: zarrDirName }).click(); // Wait for zarr metadata to load - await page.waitForSelector('text=zarr.json', { timeout: 10000 }); + await page.waitForSelector('text=zarr.json'); }; test.describe('Data Link Operations', () => { @@ -89,25 +89,25 @@ test.describe('Data Link Operations', () => { await test.step('Delete data link via properties panel', async () => { await dataLinkToggle.click(); - await expect(confirmDeleteButton).toBeVisible({ timeout: 5000 }); + await expect(confirmDeleteButton).toBeVisible(); await confirmDeleteButton.click(); await expect( page.getByText('Successfully deleted data link') ).toBeVisible(); - await expect(dataLinkToggle).not.toBeChecked({ timeout: 10000 }); + await expect(dataLinkToggle).not.toBeChecked(); }); await test.step('Recreate data link via properties panel', async () => { await expect( page.getByText('Successfully deleted data link') - ).not.toBeVisible({ timeout: 10000 }); + ).not.toBeVisible(); await dataLinkToggle.click(); // Navigate back to the zarr directory to check data link status; the above click takes you to Neuroglancer await navigateToZarrDir(page, testDir, zarrDirName); - await page.waitForLoadState('domcontentloaded', { timeout: 10000 }); + await page.waitForLoadState('domcontentloaded'); - await expect(dataLinkToggle).toBeChecked({ timeout: 10000 }); + await expect(dataLinkToggle).toBeChecked(); }); await test.step('Delete the link via action menu on links page', async () => { @@ -125,21 +125,21 @@ test.describe('Data Link Operations', () => { const deleteLinkOption = page.getByRole('menuitem', { name: /unshare/i }); await deleteLinkOption.click(); // Confirm deletion - await expect(confirmDeleteButton).toBeVisible({ timeout: 10000 }); + await expect(confirmDeleteButton).toBeVisible(); await confirmDeleteButton.click(); // Verify the link is removed from the table - await expect(linkRow).not.toBeVisible({ timeout: 10000 }); + await expect(linkRow).not.toBeVisible(); }); await test.step('Copy link works when automatic links is on and no data link exists yet', async () => { await navigateToZarrDir(page, testDir, zarrDirName); - await page.waitForLoadState('domcontentloaded', { timeout: 10000 }); + await page.waitForLoadState('domcontentloaded'); - await expect(page.getByText('zarr.json')).toBeVisible({ timeout: 10000 }); + await expect(page.getByText('zarr.json')).toBeVisible(); const copyLinkIcon = page.getByRole('button', { name: 'Copy data URL' }); - await expect(copyLinkIcon).toBeVisible({ timeout: 10000 }); + await expect(copyLinkIcon).toBeVisible(); await copyLinkIcon.click(); await expect(page.getByText('Copied!')).toBeVisible(); @@ -206,7 +206,7 @@ test.describe('Data Link Operations', () => { // Navigate into the zarr directory await page.getByRole('link', { name: zarrDirName }).click(); - await expect(page.getByText('zarr.json')).toBeVisible({ timeout: 10000 }); + await expect(page.getByText('zarr.json')).toBeVisible(); await expect(page.getByAltText(/neuroglancer/i)).toBeVisible(); // Click on the s0 subdirectory row to select it as the properties target @@ -217,9 +217,9 @@ test.describe('Data Link Operations', () => { const propertiesPanel = page .locator('[role="complementary"]') .filter({ hasText: 'Properties' }); - await expect(propertiesPanel.getByText('s0', { exact: true })).toBeVisible({ - timeout: 10000 - }); + await expect( + propertiesPanel.getByText('s0', { exact: true }) + ).toBeVisible(); // Click the Neuroglancer viewer icon — this should create a data link // for the zarr directory (currentFileOrFolder), not for s0 (propertiesTarget) @@ -230,7 +230,7 @@ test.describe('Data Link Operations', () => { const confirmButton = page.getByRole('button', { name: /confirm|create|yes/i }); - await expect(confirmButton).toBeVisible({ timeout: 5000 }); + await expect(confirmButton).toBeVisible(); await confirmButton.click(); await expect( @@ -245,9 +245,7 @@ test.describe('Data Link Operations', () => { await expect(page.getByRole('heading', { name: /links/i })).toBeVisible(); // The data link should be for the zarr directory, not the s0 subdirectory - await expect(page.getByText(zarrDirName, { exact: true })).toBeVisible({ - timeout: 10000 - }); + await expect(page.getByText(zarrDirName, { exact: true })).toBeVisible(); await expect(page.getByText('s0', { exact: true })).not.toBeVisible(); }); }); diff --git a/frontend/ui-tests/utils/navigation.ts b/frontend/ui-tests/utils/navigation.ts index b88517b49..62f58d53b 100644 --- a/frontend/ui-tests/utils/navigation.ts +++ b/frontend/ui-tests/utils/navigation.ts @@ -14,7 +14,7 @@ const navigateToScratchFsp = async (page: Page) => { .filter({ hasNotText: 'zarr' }) .nth(0); - await expect(scratchFsp).toBeVisible({ timeout: 10000 }); + await expect(scratchFsp).toBeVisible(); // Wait for file directory to load await scratchFsp.click(); From 02de8bc43aa256bb9aeccf6ee00091c271414921 Mon Sep 17 00:00:00 2001 From: allison-truhlar Date: Fri, 13 Mar 2026 09:06:22 -0400 Subject: [PATCH 074/104] tests: wait for API calls to complete rather than DOM elements --- frontend/ui-tests/utils/navigation.ts | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/frontend/ui-tests/utils/navigation.ts b/frontend/ui-tests/utils/navigation.ts index 62f58d53b..349c01bcd 100644 --- a/frontend/ui-tests/utils/navigation.ts +++ b/frontend/ui-tests/utils/navigation.ts @@ -16,10 +16,14 @@ const navigateToScratchFsp = async (page: Page) => { await expect(scratchFsp).toBeVisible(); - // Wait for file directory to load - await scratchFsp.click(); - await page.waitForLoadState('domcontentloaded', { timeout: 10000 }); - await expect(page.getByText('Name', { exact: true })).toBeVisible(); + // Wait for file directory to load by waiting for the API response + await Promise.all([ + page.waitForResponse( + response => + response.url().includes('/api/files/') && response.status() === 200 + ), + scratchFsp.click() + ]); }; const navigateToTestDir = async (page: Page, testDir: string) => { @@ -27,8 +31,13 @@ const navigateToTestDir = async (page: Page, testDir: string) => { const testDirName = testDir.split('/').pop(); console.log(`[Fixture] Navigating to test directory: ${testDirName}`); const testDirLink = page.getByRole('link', { name: testDirName }); - await testDirLink.click(); - await page.waitForLoadState('domcontentloaded'); + await Promise.all([ + page.waitForResponse( + response => + response.url().includes('/api/files/') && response.status() === 200 + ), + testDirLink.click() + ]); }; export { navigateToScratchFsp, navigateToTestDir }; From 3b9373cfc67480156e45ef2b8f86fc0a573c56ca Mon Sep 17 00:00:00 2001 From: allison-truhlar Date: Fri, 13 Mar 2026 09:07:29 -0400 Subject: [PATCH 075/104] tests, chore: extend timeout for data link operations tests --- frontend/ui-tests/tests/data-link-operations.spec.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/frontend/ui-tests/tests/data-link-operations.spec.ts b/frontend/ui-tests/tests/data-link-operations.spec.ts index 8712fc9f1..db94ebab3 100644 --- a/frontend/ui-tests/tests/data-link-operations.spec.ts +++ b/frontend/ui-tests/tests/data-link-operations.spec.ts @@ -22,6 +22,8 @@ const navigateToZarrDir = async ( }; test.describe('Data Link Operations', () => { + // These tests involve multiple full-page navigations with API calls + test.setTimeout(60_000); test.beforeEach( 'Wait for Zarr directories to load', async ({ fileglancerPage: page }) => { From 7b3ecbeda5dc9d65dd801337f635967d9af6baa6 Mon Sep 17 00:00:00 2001 From: allison-truhlar Date: Fri, 13 Mar 2026 09:08:24 -0400 Subject: [PATCH 076/104] tests, chore: make sure DOM elements are visible before interacting with them --- frontend/ui-tests/tests/data-link-operations.spec.ts | 2 ++ frontend/ui-tests/utils/navigation.ts | 2 ++ 2 files changed, 4 insertions(+) diff --git a/frontend/ui-tests/tests/data-link-operations.spec.ts b/frontend/ui-tests/tests/data-link-operations.spec.ts index db94ebab3..2e889c3e0 100644 --- a/frontend/ui-tests/tests/data-link-operations.spec.ts +++ b/frontend/ui-tests/tests/data-link-operations.spec.ts @@ -10,6 +10,8 @@ const navigateToZarrDir = async ( await page.goto('/browse', { waitUntil: 'domcontentloaded' }); + // Make sure the full page content has loaded before interacting with the file browser + await expect(page.getByText('Recently viewed')).toBeVisible(); await navigateToScratchFsp(page); const testDirName = testDir.split('/').pop() || testDir; const fullTestPath = testDirName.startsWith('test-') diff --git a/frontend/ui-tests/utils/navigation.ts b/frontend/ui-tests/utils/navigation.ts index 349c01bcd..db84ebb68 100644 --- a/frontend/ui-tests/utils/navigation.ts +++ b/frontend/ui-tests/utils/navigation.ts @@ -5,6 +5,8 @@ const navigateToScratchFsp = async (page: Page) => { const localZone = page .getByLabel('List of file share paths') .getByRole('button', { name: 'Local' }); + // Wait for the Local zone to be visible before clicking + await expect(localZone).toBeVisible(); // Click specifically on the text to avoid clicking the favorite button await localZone.getByText('Local').click(); From a3fdadfad3bb464e4ea5598b38bb5f3de4901157 Mon Sep 17 00:00:00 2001 From: Allison Truhlar Date: Wed, 8 Apr 2026 15:33:24 -0400 Subject: [PATCH 077/104] chore: alphabetize package.json and package-lock deps --- frontend/package-lock.json | 2 +- frontend/package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 8a0293622..8c6d335be 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -14,8 +14,8 @@ "@tanstack/react-hotkeys": "^0.4.1", "@tanstack/react-query": "^5.90.2", "@tanstack/react-table": "^8.21.3", - "@types/js-yaml": "^4.0.9", "@tanstack/react-virtual": "^3.13.23", + "@types/js-yaml": "^4.0.9", "@types/react-syntax-highlighter": "^15.5.13", "@vitejs/plugin-react": "^5.0.0", "autoprefixer": "^10.4.21", diff --git a/frontend/package.json b/frontend/package.json index 2c69a86f5..788904f75 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -35,8 +35,8 @@ "@tanstack/react-hotkeys": "^0.4.1", "@tanstack/react-query": "^5.90.2", "@tanstack/react-table": "^8.21.3", - "@types/js-yaml": "^4.0.9", "@tanstack/react-virtual": "^3.13.23", + "@types/js-yaml": "^4.0.9", "@types/react-syntax-highlighter": "^15.5.13", "@vitejs/plugin-react": "^5.0.0", "autoprefixer": "^10.4.21", From 3c990315a838391173bccba79727f4a3522a33be Mon Sep 17 00:00:00 2001 From: Allison Truhlar Date: Wed, 8 Apr 2026 15:55:22 -0400 Subject: [PATCH 078/104] fix: set neuroglancer omero_metadata to true --- frontend/public/viewers/neuroglancer.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/public/viewers/neuroglancer.yaml b/frontend/public/viewers/neuroglancer.yaml index 9b395a8e5..4ccebd234 100644 --- a/frontend/public/viewers/neuroglancer.yaml +++ b/frontend/public/viewers/neuroglancer.yaml @@ -38,4 +38,4 @@ capabilities: bioformats2raw_layout: false # Is the OMERO metadata used to e.g. color the channels? - omero_metadata: false + omero_metadata: true From 0c9494a71edcaf1012d7f7987dc50a4be563d91a Mon Sep 17 00:00:00 2001 From: Allison Truhlar Date: Wed, 8 Apr 2026 15:58:48 -0400 Subject: [PATCH 079/104] refactor: rename vizarr.yaml to avivator.yaml --- docs/ViewersConfiguration.md | 4 ++-- frontend/public/viewers/{vizarr.yaml => avivator.yaml} | 0 .../__tests__/componentTests/DataToolLinks.test.tsx | 10 +++++----- frontend/src/__tests__/unitTests/viewersConfig.test.ts | 4 ++-- frontend/src/config/viewers.config.yaml | 2 +- 5 files changed, 10 insertions(+), 10 deletions(-) rename frontend/public/viewers/{vizarr.yaml => avivator.yaml} (100%) diff --git a/docs/ViewersConfiguration.md b/docs/ViewersConfiguration.md index 554db0c51..97a298af2 100644 --- a/docs/ViewersConfiguration.md +++ b/docs/ViewersConfiguration.md @@ -154,7 +154,7 @@ Use `instance_template_url` to point to a custom deployment of a viewer while st ```yaml viewers: - - manifest_url: "https://raw.githubusercontent.com/JaneliaSciComp/fileglancer/main/frontend/public/viewers/vizarr.yaml" + - manifest_url: "https://raw.githubusercontent.com/JaneliaSciComp/fileglancer/main/frontend/public/viewers/avivator.yaml" instance_template_url: "https://my-avivator-instance.example.com/?image_url={dataLink}" logo: avivator.png ``` @@ -234,7 +234,7 @@ Just add `frontend/src/assets/neuroglancer.png` -- no config needed. ```yaml viewers: - - manifest_url: "https://example.com/manifests/vizarr.yaml" + - manifest_url: "https://example.com/manifests/avivator.yaml" logo: "avivator.png" # Uses @/assets/avivator.png ``` diff --git a/frontend/public/viewers/vizarr.yaml b/frontend/public/viewers/avivator.yaml similarity index 100% rename from frontend/public/viewers/vizarr.yaml rename to frontend/public/viewers/avivator.yaml diff --git a/frontend/src/__tests__/componentTests/DataToolLinks.test.tsx b/frontend/src/__tests__/componentTests/DataToolLinks.test.tsx index 35c154164..93421d147 100644 --- a/frontend/src/__tests__/componentTests/DataToolLinks.test.tsx +++ b/frontend/src/__tests__/componentTests/DataToolLinks.test.tsx @@ -154,10 +154,10 @@ describe('DataToolLinks - Edge Cases', () => { template_url: 'https://neuroglancer.com/#!{DATA_URL}' } }, - vizarr: { + avivator: { viewer: { name: 'Avivator', - template_url: 'https://vizarr.com/?url={DATA_URL}' + template_url: 'https://avivator.com/?url={DATA_URL}' } } }; @@ -207,7 +207,7 @@ describe('DataToolLinks - Edge Cases', () => { expect(neuroglancerLogo).toBeTruthy(); expect(neuroglancerLogo?.getAttribute('src')).toContain('neuroglancer'); - // Check for avivator logo (name for viewer in vizarr.yaml) + // Check for avivator logo (name for viewer in avivator.yaml) const vizarrLogo = images.find( img => img.getAttribute('alt') === 'View in Avivator' ); @@ -269,10 +269,10 @@ describe('DataToolLinks - Expected Behavior', () => { template_url: 'https://neuroglancer.com/#!{DATA_URL}' } }, - vizarr: { + avivator: { viewer: { name: 'Avivator', - template_url: 'https://vizarr.com/?url={DATA_URL}' + template_url: 'https://avivator.com/?url={DATA_URL}' } } }; diff --git a/frontend/src/__tests__/unitTests/viewersConfig.test.ts b/frontend/src/__tests__/unitTests/viewersConfig.test.ts index 3277ccdb2..af8701280 100644 --- a/frontend/src/__tests__/unitTests/viewersConfig.test.ts +++ b/frontend/src/__tests__/unitTests/viewersConfig.test.ts @@ -324,14 +324,14 @@ viewers: const yaml = ` viewers: - manifest_url: /viewers/neuroglancer.yaml - - manifest_url: /viewers/vizarr.yaml + - manifest_url: /viewers/avivator.yaml `; const result = parseViewersConfig(yaml); expect(result.viewers).toHaveLength(2); expect(result.viewers[0].manifest_url).toBe('/viewers/neuroglancer.yaml'); - expect(result.viewers[1].manifest_url).toBe('/viewers/vizarr.yaml'); + expect(result.viewers[1].manifest_url).toBe('/viewers/avivator.yaml'); }); it('should handle URL with special characters', () => { diff --git a/frontend/src/config/viewers.config.yaml b/frontend/src/config/viewers.config.yaml index 689d36ef3..fa972e6d1 100644 --- a/frontend/src/config/viewers.config.yaml +++ b/frontend/src/config/viewers.config.yaml @@ -4,7 +4,7 @@ viewers: - manifest_url: 'https://raw.githubusercontent.com/JaneliaSciComp/fileglancer/viewers-config/frontend/public/viewers/neuroglancer.yaml' - - manifest_url: 'https://raw.githubusercontent.com/JaneliaSciComp/fileglancer/viewers-config/frontend/public/viewers/vizarr.yaml' + - manifest_url: 'https://raw.githubusercontent.com/JaneliaSciComp/fileglancer/viewers-config/frontend/public/viewers/avivator.yaml' - manifest_url: 'https://raw.githubusercontent.com/JaneliaSciComp/fileglancer/viewers-config/frontend/public/viewers/validator.yaml' From 3abd410c9c214bdb0d5de5410ff75b184639a6c3 Mon Sep 17 00:00:00 2001 From: Allison Truhlar Date: Wed, 8 Apr 2026 16:08:17 -0400 Subject: [PATCH 080/104] refactor: merge viewers config template into default config --- CLAUDE.md | 2 +- docs/Development.md | 2 +- docs/ViewersConfiguration.md | 6 +++--- frontend/src/config/viewers.config.yaml | 18 +++++++++++++++-- frontend/viewers.config.yaml.template | 26 ------------------------- 5 files changed, 21 insertions(+), 33 deletions(-) delete mode 100644 frontend/viewers.config.yaml.template diff --git a/CLAUDE.md b/CLAUDE.md index 5e059f8b3..5a359710c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -180,7 +180,7 @@ Fileglancer uses a manifest-based viewer configuration system. Each viewer is de To customize viewers: -1. Copy `frontend/viewers.config.yaml.template` to `frontend/viewers.config.yaml` +1. Copy `frontend/src/config/viewers.config.yaml` to `frontend/viewers.config.yaml` 2. Edit `frontend/viewers.config.yaml` (add/remove `manifest_url` entries, override URLs or labels) 3. Rebuild application: `pixi run node-build` diff --git a/docs/Development.md b/docs/Development.md index b40aecb28..1918d8e98 100644 --- a/docs/Development.md +++ b/docs/Development.md @@ -74,7 +74,7 @@ Fileglancer supports dynamic configuration of OME-Zarr viewers through `viewers. **Quick Setup:** -1. Copy the template into the frontend directory: `cp frontend/viewers.config.yaml.template frontend/viewers.config.yaml` +1. Copy the default config: `cp frontend/src/config/viewers.config.yaml frontend/viewers.config.yaml` 2. Edit `frontend/viewers.config.yaml` to enable/disable viewers or customize URLs diff --git a/docs/ViewersConfiguration.md b/docs/ViewersConfiguration.md index 97a298af2..c5779c118 100644 --- a/docs/ViewersConfiguration.md +++ b/docs/ViewersConfiguration.md @@ -15,7 +15,7 @@ Each viewer is defined by a **capability manifest** hosted at a URL. The configu ## Quick Start -1. Copy the template into the frontend directory: `cp frontend/viewers.config.yaml.template frontend/viewers.config.yaml` +1. Copy the default config: `cp frontend/src/config/viewers.config.yaml frontend/viewers.config.yaml` 2. Edit `frontend/viewers.config.yaml` to customize viewers 3. Rebuild the application: `pixi run node-build` @@ -30,7 +30,7 @@ There are two config locations, with the `frontend/` override taking precedence | `frontend/viewers.config.yaml` | **Local override** — gitignored, safe to customize without merge conflicts | | `frontend/src/config/viewers.config.yaml` | **Default config** — committed source file, used when no override exists | -Copy `frontend/viewers.config.yaml.template` to `frontend/viewers.config.yaml` to create a local override. This file is listed in `.gitignore` so your customizations will not conflict with upstream updates. +Copy `frontend/src/config/viewers.config.yaml` to `frontend/viewers.config.yaml` to create a local override. This file is listed in `.gitignore` so your customizations will not conflict with upstream updates. **Important:** The config is bundled at build time. Changes require rebuilding the application. @@ -242,7 +242,7 @@ viewers: When developing with custom configurations: -1. Copy `frontend/viewers.config.yaml.template` to `frontend/viewers.config.yaml` +1. Copy the default config: `cp frontend/src/config/viewers.config.yaml frontend/viewers.config.yaml` 2. Edit `frontend/viewers.config.yaml` 3. Rebuild frontend: `pixi run node-build` or use watch mode: `pixi run dev-watch` 4. Check the browser console for viewer initialization messages diff --git a/frontend/src/config/viewers.config.yaml b/frontend/src/config/viewers.config.yaml index fa972e6d1..3682349db 100644 --- a/frontend/src/config/viewers.config.yaml +++ b/frontend/src/config/viewers.config.yaml @@ -1,5 +1,19 @@ -# Default Fileglancer OME-Zarr Viewers Configuration -# Can be overridden by providing a custom viewers.config.yaml file in the frontend/ directory +# Fileglancer OME-Zarr Viewers Configuration +# +# To customize, copy this file to frontend/viewers.config.yaml (which is +# gitignored) and edit your copy. The override takes precedence at build time. +# +# After editing, rebuild with: pixi run node-build +# (or use watch mode: pixi run dev-watch) +# +# Each viewer entry requires: +# - manifest_url: URL or absolute path to a capability manifest YAML file +# Use absolute paths (e.g. /viewers/neuroglancer.yaml) for manifests bundled +# in the public/ directory, or full URLs for externally hosted manifests. +# +# Optional: +# - instance_template_url: Override the viewer's template_url from the manifest +# - label: Custom tooltip text (defaults to "View in {Name}") viewers: - manifest_url: 'https://raw.githubusercontent.com/JaneliaSciComp/fileglancer/viewers-config/frontend/public/viewers/neuroglancer.yaml' diff --git a/frontend/viewers.config.yaml.template b/frontend/viewers.config.yaml.template deleted file mode 100644 index 8483e40fc..000000000 --- a/frontend/viewers.config.yaml.template +++ /dev/null @@ -1,26 +0,0 @@ -# Fileglancer OME-Zarr Viewers Configuration -# -# Copy this file to frontend/viewers.config.yaml to override the default -# viewer configuration, located at frontend/src/config/viewers.config.yaml. -# -# After editing, rebuild with: pixi run node-build -# (or use watch mode: pixi run dev-watch) -# -# Each viewer entry requires: -# - manifest_url: URL or absolute path to a capability manifest YAML file -# Use absolute paths (e.g. /viewers/neuroglancer.yaml) for manifests bundled -# in the public/ directory, or full URLs for externally hosted manifests. -# -# Optional: -# - instance_template_url: Override the viewer's template_url from the manifest -# - logo: Filename of logo in frontend/src/assets/ (defaults to {normalized_name}.png) -# - label: Custom tooltip text (defaults to "View in {Name}") - -viewers: - - manifest_url: 'https://raw.githubusercontent.com/JaneliaSciComp/fileglancer/viewers-config/frontend/public/viewers/neuroglancer.yaml' - - - manifest_url: 'https://raw.githubusercontent.com/JaneliaSciComp/fileglancer/viewers-config/frontend/public/viewers/vizarr.yaml' - - - manifest_url: 'https://raw.githubusercontent.com/JaneliaSciComp/fileglancer/viewers-config/frontend/public/viewers/validator.yaml' - - - manifest_url: 'https://raw.githubusercontent.com/JaneliaSciComp/fileglancer/viewers-config/frontend/public/viewers/vole.yaml' From 3c9bdc309277e30ef5e961999a80b9ea72151579 Mon Sep 17 00:00:00 2001 From: Allison Truhlar Date: Wed, 8 Apr 2026 16:09:08 -0400 Subject: [PATCH 081/104] refactor: add gitignored custom-logos directory for viewer logos --- .gitignore | 3 +++ docs/ViewersConfiguration.md | 26 ++++++++++++++++--------- frontend/src/config/viewerLogos.ts | 15 ++++++++------ frontend/src/config/viewers.config.yaml | 2 ++ 4 files changed, 31 insertions(+), 15 deletions(-) diff --git a/.gitignore b/.gitignore index 76fb01fe6..e6eddc9b4 100644 --- a/.gitignore +++ b/.gitignore @@ -145,6 +145,9 @@ style/tailwind_converted.css config.yaml frontend/viewers.config.yaml +# Custom viewer logos +frontend/src/assets/custom-logos/ + # Claude Code .claude diff --git a/docs/ViewersConfiguration.md b/docs/ViewersConfiguration.md index c5779c118..cad642f0b 100644 --- a/docs/ViewersConfiguration.md +++ b/docs/ViewersConfiguration.md @@ -45,7 +45,7 @@ The configuration file has a single top-level key, `viewers`, containing a list | `manifest_url` | Yes | URL to a capability manifest YAML file | | `instance_template_url` | No | Override the viewer's `template_url` from the manifest | | `label` | No | Custom tooltip text (defaults to "View in {Name}") | -| `logo` | No | Filename of logo in `frontend/src/assets/` (defaults to `{normalized_name}.png`) | +| `logo` | No | Filename of logo in `frontend/src/assets/` or `frontend/src/assets/custom-logos/` (defaults to `{normalized_name}.png`) | ### Default Configuration @@ -55,7 +55,7 @@ The default `viewers.config.yaml` configures four viewers: viewers: - manifest_url: "https://raw.githubusercontent.com/JaneliaSciComp/fileglancer/main/frontend/public/viewers/neuroglancer.yaml" - - manifest_url: "https://raw.githubusercontent.com/JaneliaSciComp/fileglancer/main/frontend/public/viewers/vizarr.yaml" + - manifest_url: "https://raw.githubusercontent.com/JaneliaSciComp/fileglancer/main/frontend/public/viewers/avivator.yaml" instance_template_url: "https://janeliascicomp.github.io/viv/?image_url={DATA_URL}" - manifest_url: "https://raw.githubusercontent.com/JaneliaSciComp/fileglancer/main/frontend/public/viewers/validator.yaml" @@ -197,7 +197,7 @@ viewers: label: "Open in My Viewer" ``` -4. Optionally, add a logo file at `frontend/src/assets/myviewer.png` (the normalized name, lowercase with non-alphanumeric characters removed). +4. Optionally, add a logo file at `frontend/src/assets/custom-logos/myviewer.png` (the normalized name, lowercase with non-alphanumeric characters removed). Using `custom-logos/` keeps your additions gitignored. ## How Compatibility Works @@ -214,10 +214,20 @@ This replaces the previous system where `valid_ome_zarr_versions` was a global c Logo resolution follows this order: -1. **Custom logo specified**: If you provide a `logo` field in the config entry, that filename is looked up in `frontend/src/assets/` -2. **Convention-based**: If no `logo` is specified, the system looks for `frontend/src/assets/{normalized_name}.png`, where the normalized name is the viewer's name lowercased with non-alphanumeric characters removed +1. **Custom logo specified**: If you provide a `logo` field in the config entry, that filename is looked up in `frontend/src/assets/` and `frontend/src/assets/custom-logos/` +2. **Convention-based**: If no `logo` is specified, the system looks for `{normalized_name}.png` in both directories, where the normalized name is the viewer's name lowercased with non-alphanumeric characters removed 3. **Fallback**: If neither is found, `frontend/src/assets/fallback_logo.png` is used +### Custom logos without git changes + +To add logos without modifying tracked files, place them in `frontend/src/assets/custom-logos/`. This directory is gitignored, so your logos won't trigger git changes or conflict with upstream updates. + +1. Create the directory: `mkdir -p frontend/src/assets/custom-logos/` +2. Place your logo PNG files there (e.g., `frontend/src/assets/custom-logos/myviewer.png`) +3. Rebuild the application: `pixi run node-build` + +Logos in `custom-logos/` are resolved the same way as logos in `assets/` — by convention-based naming or by the `logo` field in the config. + ### Examples **Using the naming convention (recommended):** @@ -225,17 +235,15 @@ Logo resolution follows this order: ```yaml viewers: - manifest_url: "https://example.com/manifests/neuroglancer.yaml" - # Logo automatically resolves to @/assets/neuroglancer.png + # Logo automatically resolves to neuroglancer.png in assets/ or assets/custom-logos/ ``` -Just add `frontend/src/assets/neuroglancer.png` -- no config needed. - **Using a custom logo filename:** ```yaml viewers: - manifest_url: "https://example.com/manifests/avivator.yaml" - logo: "avivator.png" # Uses @/assets/avivator.png + logo: "avivator.png" # Looked up in assets/ and assets/custom-logos/ ``` ## Development diff --git a/frontend/src/config/viewerLogos.ts b/frontend/src/config/viewerLogos.ts index cbc13369e..752a202e5 100644 --- a/frontend/src/config/viewerLogos.ts +++ b/frontend/src/config/viewerLogos.ts @@ -1,12 +1,15 @@ import fallback_logo from '@/assets/fallback_logo.png'; /** - * Map of all available logo files in the assets directory + * Map of all available logo files in assets/ and assets/custom-logos/ * This is populated at build time by Vite's glob import */ -const LOGO_MODULES = import.meta.glob<{ default: string }>('@/assets/*.png', { - eager: true -}); +const LOGO_MODULES = import.meta.glob<{ default: string }>( + ['@/assets/*.png', '@/assets/custom-logos/*.png'], + { + eager: true + } +); /** * Extract filename from glob import path @@ -20,8 +23,8 @@ function extractFileName(path: string): string { /** * Get logo path for a viewer * Logo resolution order: - * 1. If customLogoPath is provided, use that from @/assets/ - * 2. If not, try to load @/assets/{viewerName}.png + * 1. If customLogoPath is provided, use that from @/assets/ or @/assets/custom-logos/ + * 2. If not, try to load {viewerName}.png from @/assets/ or @/assets/custom-logos/ * 3. If not found, use fallback logo * * @param viewerName - Name of the viewer (case-insensitive) diff --git a/frontend/src/config/viewers.config.yaml b/frontend/src/config/viewers.config.yaml index 3682349db..8c5b8beaf 100644 --- a/frontend/src/config/viewers.config.yaml +++ b/frontend/src/config/viewers.config.yaml @@ -13,6 +13,8 @@ # # Optional: # - instance_template_url: Override the viewer's template_url from the manifest +# - logo: Filename of logo in frontend/src/assets/ or frontend/src/assets/custom-logos/ +# (defaults to {normalized_name}.png; custom-logos/ is gitignored for local overrides) # - label: Custom tooltip text (defaults to "View in {Name}") viewers: From 1c23561f846fb074bbd14a91619e4aad237a9cd4 Mon Sep 17 00:00:00 2001 From: Allison Truhlar Date: Tue, 14 Apr 2026 15:12:02 -0400 Subject: [PATCH 082/104] chore: bump capability-manifest version --- frontend/package-lock.json | 8 ++++---- frontend/package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 8c6d335be..a53973fd7 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -9,7 +9,7 @@ "version": "2.7.0-a4", "license": "BSD-3-Clause", "dependencies": { - "@bioimagetools/capability-manifest": "^0.3.3", + "@bioimagetools/capability-manifest": "^0.4.0", "@material-tailwind/react": "^3.0.0-beta.24", "@tanstack/react-hotkeys": "^0.4.1", "@tanstack/react-query": "^5.90.2", @@ -406,9 +406,9 @@ } }, "node_modules/@bioimagetools/capability-manifest": { - "version": "0.3.3", - "resolved": "https://registry.npmjs.org/@bioimagetools/capability-manifest/-/capability-manifest-0.3.3.tgz", - "integrity": "sha512-McaIsgGyrxRxdQbDmek8On7PeSFA47pYOrfSudvd0d+VtZXX0VCYzq4RmJswVT+h19Bi4b4vTIinhJE0ACsCwA==", + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@bioimagetools/capability-manifest/-/capability-manifest-0.4.0.tgz", + "integrity": "sha512-/7X7n0M+bnphBAgbR7OZdbVqYlydHeGVwXv5t4+gFBptY6Q+oEZpIpPThzHP+0EA7OhVzqLHfb3OgmdhovCQeg==", "license": "ISC", "dependencies": { "js-yaml": "^4.1.1" diff --git a/frontend/package.json b/frontend/package.json index 788904f75..1e6208e6f 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -30,7 +30,7 @@ "test": "vitest" }, "dependencies": { - "@bioimagetools/capability-manifest": "^0.3.3", + "@bioimagetools/capability-manifest": "^0.4.0", "@material-tailwind/react": "^3.0.0-beta.24", "@tanstack/react-hotkeys": "^0.4.1", "@tanstack/react-query": "^5.90.2", From 6bf9c9d83b69c56eeb582d6cf84dae1d8c5c03fd Mon Sep 17 00:00:00 2001 From: Allison Truhlar Date: Tue, 14 Apr 2026 15:23:27 -0400 Subject: [PATCH 083/104] fix: update to result.dataCompatible to match new API --- frontend/src/contexts/ViewersContext.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/src/contexts/ViewersContext.tsx b/frontend/src/contexts/ViewersContext.tsx index 2fc54eb18..7264b08aa 100644 --- a/frontend/src/contexts/ViewersContext.tsx +++ b/frontend/src/contexts/ViewersContext.tsx @@ -196,7 +196,7 @@ export function ViewersProvider({ return validViewers.filter(viewer => { const result = validateViewer(viewer.manifest, metadata); - if (!result.compatible) { + if (!result.dataCompatible) { log.info( `Viewer "${viewer.displayName}" is not compatible with this dataset: ${result.errors.map(e => e.message).join('; ')}` ); @@ -206,7 +206,7 @@ export function ViewersProvider({ `Viewer "${viewer.displayName}" warnings: ${result.warnings.map(w => w.message).join('; ')}` ); } - return result.compatible; + return result.dataCompatible; }); }, [validViewers, isInitialized] From 0c6effb3c2baecc1411c908a8c1b25caa719da3f Mon Sep 17 00:00:00 2001 From: Allison Truhlar Date: Tue, 14 Apr 2026 20:24:35 +0000 Subject: [PATCH 084/104] chore: bump capability-manifest to 0.5.0 --- frontend/package-lock.json | 28 ++++++++++++++++++++-------- frontend/package.json | 4 ++-- 2 files changed, 22 insertions(+), 10 deletions(-) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index a53973fd7..b4a9ba49c 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -9,7 +9,7 @@ "version": "2.7.0-a4", "license": "BSD-3-Clause", "dependencies": { - "@bioimagetools/capability-manifest": "^0.4.0", + "@bioimagetools/capability-manifest": "0.5.0", "@material-tailwind/react": "^3.0.0-beta.24", "@tanstack/react-hotkeys": "^0.4.1", "@tanstack/react-query": "^5.90.2", @@ -72,6 +72,23 @@ "vitest": "^3.1.3" } }, + "../../capability-manifest": { + "name": "@bioimagetools/capability-manifest", + "version": "0.5.0", + "license": "ISC", + "dependencies": { + "js-yaml": "^4.1.1" + }, + "devDependencies": { + "@types/js-yaml": "^4.0.9", + "@types/node": "^24.10.1", + "ome-zarr.js": "^0.0.17", + "typescript": "^5.9.3", + "vite": "^7.2.2", + "vitest": "^4.0.18", + "zarrita": "^0.5.4" + } + }, "node_modules/@adobe/css-tools": { "version": "4.4.4", "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.4.tgz", @@ -406,13 +423,8 @@ } }, "node_modules/@bioimagetools/capability-manifest": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/@bioimagetools/capability-manifest/-/capability-manifest-0.4.0.tgz", - "integrity": "sha512-/7X7n0M+bnphBAgbR7OZdbVqYlydHeGVwXv5t4+gFBptY6Q+oEZpIpPThzHP+0EA7OhVzqLHfb3OgmdhovCQeg==", - "license": "ISC", - "dependencies": { - "js-yaml": "^4.1.1" - } + "resolved": "../../capability-manifest", + "link": true }, "node_modules/@emnapi/core": { "version": "1.7.1", diff --git a/frontend/package.json b/frontend/package.json index 1e6208e6f..50e5a5f46 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -30,7 +30,7 @@ "test": "vitest" }, "dependencies": { - "@bioimagetools/capability-manifest": "^0.4.0", + "@bioimagetools/capability-manifest": "0.5.0", "@material-tailwind/react": "^3.0.0-beta.24", "@tanstack/react-hotkeys": "^0.4.1", "@tanstack/react-query": "^5.90.2", @@ -40,8 +40,8 @@ "@types/react-syntax-highlighter": "^15.5.13", "@vitejs/plugin-react": "^5.0.0", "autoprefixer": "^10.4.21", - "js-yaml": "^4.1.1", "fracturedjsonjs": "^5.0.1", + "js-yaml": "^4.1.1", "loglevel": "^1.9.2", "npm-run-all2": "^7.0.2", "ome-zarr.js": "^0.0.17", From b9a32b3b9aaa0a051e3e96fb7cb9aea01a534aed Mon Sep 17 00:00:00 2001 From: Allison Truhlar Date: Tue, 14 Apr 2026 20:24:39 +0000 Subject: [PATCH 085/104] refactor: use getLogoUrl from capability-manifest library --- frontend/src/components/ui/BrowsePage/DataToolLinks.tsx | 4 ++++ frontend/src/contexts/ViewersContext.tsx | 4 ++-- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/frontend/src/components/ui/BrowsePage/DataToolLinks.tsx b/frontend/src/components/ui/BrowsePage/DataToolLinks.tsx index 57658e004..7598bf688 100644 --- a/frontend/src/components/ui/BrowsePage/DataToolLinks.tsx +++ b/frontend/src/components/ui/BrowsePage/DataToolLinks.tsx @@ -1,5 +1,6 @@ import { Typography } from '@material-tailwind/react'; import { Link } from 'react-router'; +import fallback_logo from '@/assets/fallback_logo.png'; import { HiOutlineClipboardCopy } from 'react-icons/hi'; import { HiOutlineEllipsisHorizontalCircle } from 'react-icons/hi2'; @@ -46,6 +47,9 @@ function ToolLink({ {logoAlt} { + e.currentTarget.src = fallback_logo; + }} src={logoSrc} /> diff --git a/frontend/src/contexts/ViewersContext.tsx b/frontend/src/contexts/ViewersContext.tsx index 7264b08aa..7119f3bc5 100644 --- a/frontend/src/contexts/ViewersContext.tsx +++ b/frontend/src/contexts/ViewersContext.tsx @@ -9,6 +9,7 @@ import { import { loadManifestsFromUrls, validateViewer, + getLogoUrl, type ViewerManifest, type OmeZarrMetadata } from '@bioimagetools/capability-manifest'; @@ -17,7 +18,6 @@ import { parseViewersConfig, type ViewerConfigEntry } from '@/config/viewersConfig'; -import { getViewerLogo } from '@/config/viewerLogos'; /** * Validated viewer with all necessary information @@ -147,7 +147,7 @@ export function ViewersProvider({ const key = normalizeViewerName(manifest.viewer.name); const displayName = manifest.viewer.name; const label = entry.label || `View in ${displayName}`; - const logoPath = getViewerLogo(manifest.viewer.name, entry.logo); + const logoPath = getLogoUrl(manifest); validated.push({ key, From f4d333f344c6ade939345f73568b17137c6d12eb Mon Sep 17 00:00:00 2001 From: Allison Truhlar Date: Tue, 14 Apr 2026 20:24:42 +0000 Subject: [PATCH 086/104] refactor: remove local viewer logo code and assets --- .gitignore | 3 - .../__tests__/unitTests/viewerLogos.test.ts | 100 ------------------ .../__tests__/unitTests/viewersConfig.test.ts | 20 +--- frontend/src/assets/avivator.png | Bin 5822 -> 0 bytes frontend/src/assets/neuroglancer.png | Bin 39096 -> 0 bytes frontend/src/assets/validator.png | Bin 434 -> 0 bytes frontend/src/assets/vol-e.png | Bin 13174 -> 0 bytes frontend/src/config/viewerLogos.ts | 50 --------- frontend/src/config/viewersConfig.ts | 3 +- 9 files changed, 2 insertions(+), 174 deletions(-) delete mode 100644 frontend/src/__tests__/unitTests/viewerLogos.test.ts delete mode 100644 frontend/src/assets/avivator.png delete mode 100644 frontend/src/assets/neuroglancer.png delete mode 100644 frontend/src/assets/validator.png delete mode 100644 frontend/src/assets/vol-e.png delete mode 100644 frontend/src/config/viewerLogos.ts diff --git a/.gitignore b/.gitignore index e6eddc9b4..76fb01fe6 100644 --- a/.gitignore +++ b/.gitignore @@ -145,9 +145,6 @@ style/tailwind_converted.css config.yaml frontend/viewers.config.yaml -# Custom viewer logos -frontend/src/assets/custom-logos/ - # Claude Code .claude diff --git a/frontend/src/__tests__/unitTests/viewerLogos.test.ts b/frontend/src/__tests__/unitTests/viewerLogos.test.ts deleted file mode 100644 index e22d216c4..000000000 --- a/frontend/src/__tests__/unitTests/viewerLogos.test.ts +++ /dev/null @@ -1,100 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { getViewerLogo } from '@/config/viewerLogos'; -import fallback_logo from '@/assets/fallback_logo.png'; - -describe('getViewerLogo', () => { - describe('Existing logo files', () => { - it('should return logo path for viewer with existing logo file', () => { - const neuroglancerLogo = getViewerLogo('neuroglancer'); - expect(neuroglancerLogo).toBeTruthy(); - expect(neuroglancerLogo).not.toBe(fallback_logo); - }); - - it('should return logo path for avivator', () => { - const avivatorLogo = getViewerLogo('avivator'); - expect(avivatorLogo).toBeTruthy(); - expect(avivatorLogo).not.toBe(fallback_logo); - }); - - it('should return logo path for validator', () => { - const validatorLogo = getViewerLogo('validator'); - expect(validatorLogo).toBeTruthy(); - expect(validatorLogo).not.toBe(fallback_logo); - }); - - it('should return logo path for vole', () => { - const voleLogo = getViewerLogo('vol-e'); - expect(voleLogo).toBeTruthy(); - expect(voleLogo).not.toBe(fallback_logo); - }); - }); - - describe('Custom logo paths', () => { - it('should return logo when custom logo path exists', () => { - // Using an existing logo file as a custom path - const customLogo = getViewerLogo('any-name', 'neuroglancer.png'); - expect(customLogo).toBeTruthy(); - expect(customLogo).not.toBe(fallback_logo); - }); - - it('should return fallback when custom logo path does not exist', () => { - const nonExistentCustomLogo = getViewerLogo('test', 'nonexistent.png'); - expect(nonExistentCustomLogo).toBe(fallback_logo); - }); - }); - - describe('Fallback logo handling', () => { - it('should return fallback logo when viewer logo file does not exist', () => { - const nonExistentViewerLogo = getViewerLogo('nonexistent_viewer'); - expect(nonExistentViewerLogo).toBe(fallback_logo); - }); - - it('should return fallback logo for custom_viewer without logo file', () => { - const customViewerLogo = getViewerLogo('custom_viewer'); - expect(customViewerLogo).toBe(fallback_logo); - }); - - it('should return fallback logo for unknown viewer names', () => { - const unknownLogo = getViewerLogo('unknown_test_viewer_xyz'); - expect(unknownLogo).toBe(fallback_logo); - }); - }); - - describe('Case handling', () => { - it('should handle lowercase viewer names', () => { - const logo = getViewerLogo('neuroglancer'); - expect(logo).toBeTruthy(); - expect(logo).not.toBe(fallback_logo); - }); - - it('should convert uppercase to lowercase for logo lookup', () => { - // getViewerLogo converts to lowercase, so 'NEUROGLANCER' -> 'neuroglancer.png' - const logo = getViewerLogo('NEUROGLANCER'); - expect(logo).toBeTruthy(); - expect(logo).not.toBe(fallback_logo); - }); - - it('should handle mixed case viewer names', () => { - const logo = getViewerLogo('NeuroGlancer'); - expect(logo).toBeTruthy(); - expect(logo).not.toBe(fallback_logo); - }); - }); - - describe('Edge cases', () => { - it('should handle empty string viewer name', () => { - const emptyLogo = getViewerLogo(''); - expect(emptyLogo).toBe(fallback_logo); - }); - - it('should handle viewer names with special characters', () => { - const specialLogo = getViewerLogo('viewer-with-dashes'); - expect(specialLogo).toBe(fallback_logo); - }); - - it('should handle viewer names with underscores', () => { - const underscoreLogo = getViewerLogo('viewer_with_underscores'); - expect(underscoreLogo).toBe(fallback_logo); - }); - }); -}); diff --git a/frontend/src/__tests__/unitTests/viewersConfig.test.ts b/frontend/src/__tests__/unitTests/viewersConfig.test.ts index af8701280..d4b87d26d 100644 --- a/frontend/src/__tests__/unitTests/viewersConfig.test.ts +++ b/frontend/src/__tests__/unitTests/viewersConfig.test.ts @@ -16,7 +16,6 @@ viewers: ); expect(result.viewers[0].instance_template_url).toBeUndefined(); expect(result.viewers[0].label).toBeUndefined(); - expect(result.viewers[0].logo).toBeUndefined(); }); it('should parse config with multiple viewers', () => { @@ -46,7 +45,6 @@ viewers: - manifest_url: https://example.com/viewer.yaml instance_template_url: https://example.com/viewer?url={dataLink} label: Custom Viewer Label - logo: custom-logo.png `; const result = parseViewersConfig(yaml); @@ -58,7 +56,6 @@ viewers: 'https://example.com/viewer?url={dataLink}' ); expect(result.viewers[0].label).toBe('Custom Viewer Label'); - expect(result.viewers[0].logo).toBe('custom-logo.png'); }); it('should parse config with manifest_url only (no optional fields)', () => { @@ -141,7 +138,6 @@ other_field: value const yaml = ` viewers: - label: Custom Label - logo: custom.png `; expect(() => parseViewersConfig(yaml)).toThrow( @@ -195,16 +191,6 @@ viewers: ); }); - it('should throw error when logo is not a string', () => { - const yaml = ` -viewers: - - manifest_url: https://example.com/viewer.yaml - logo: 123 -`; - - expect(() => parseViewersConfig(yaml)).toThrow(/"logo" must be a string/); - }); - it('should throw error when instance_template_url is not a string', () => { const yaml = ` viewers: @@ -271,7 +257,6 @@ viewers: - manifest_url: https://example.com/viewer.yaml instance_template_url: https://example.com/viewer?url={dataLink} label: Custom Label - logo: custom.png `; const result = parseViewersConfig(yaml); @@ -279,8 +264,7 @@ viewers: expect(result.viewers[0]).toEqual({ manifest_url: 'https://example.com/viewer.yaml', instance_template_url: 'https://example.com/viewer?url={dataLink}', - label: 'Custom Label', - logo: 'custom.png' + label: 'Custom Label' }); }); @@ -352,7 +336,6 @@ viewers: viewers: - manifest_url: https://example.com/viewer.yaml label: "" - logo: "" instance_template_url: "" `; @@ -361,7 +344,6 @@ viewers: expect(result.viewers[0]).toEqual({ manifest_url: 'https://example.com/viewer.yaml', label: '', - logo: '', instance_template_url: '' }); }); diff --git a/frontend/src/assets/avivator.png b/frontend/src/assets/avivator.png deleted file mode 100644 index 2d07b8cfce868d0ebc0a8dc4c293c3e01d38c85f..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 5822 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 diff --git a/frontend/src/assets/validator.png b/frontend/src/assets/validator.png deleted file mode 100644 index b96a530961a33770f81374545e375f4888b27449..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 434 zcmV;j0ZsmiP)Px$Y)M2xR7gw3m9Z{_K@i7(*LVPl&O1o1qtbc+LMT-T4K-?-6FQ}Y@Bm6GI*E7C zNj$(+@|T;-WV7?_ek);9$00iLXrn_s&SSYbd}DaMc*PiJQB! z@Q_^ro`B^BKq@W?iThGLcL8#BwK!_TWg7xiQ;DPz$*ER61emx*>mhx_LqH@V2f_Wy zW$5?A66GN2Jt&D!GuQ-fGPkG;QX?+OE!`sZ{P*CY^{u&&Qx9#Srd4=IZR7$?TZR6V znw=n8X}_s%b~4nUxovpSIhPF^JbyZHEE4tc%ZAyYj=X1s#>D<*==+*#R_I`(!`0#I c_ZgktH>+D!f9|!lB>(^b07*qoM6N<$g3c7d3jhEB diff --git a/frontend/src/assets/vol-e.png b/frontend/src/assets/vol-e.png deleted file mode 100644 index e5e00744c93a422586adc1df241fdee0423f9866..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 13174 zcmZX419T_P(r9dZ!(VLMcCvA@+1R$dv2EMt#{)M zIiHW_Ex+jtimGEe)#l!kk>s3p-gex1Ib3Bkn|vx=%3SgSnR;(ak-U^(0D#sHe30?*$^<%&zhjWw`5|J4CuSo zcB(QJd5cIsNH>X2kU)h{2goB*9`h;W27RxoF7|k%S1SM?)gz_v$No3<&+U ze{+;X0y9WL-~rkcF@a=dmTc1GWF?-t4=ajn(uMp(i3ras&+dRJY2(u8EhunYbp9d* z8X2j;6p&h#SPD`IefSV9L$^=`s&s_`A?NX&Nad`YkS&;9FkcRyUn9}zM$&yK%8!NM zbv>T76GF)3Vu4SXF!4kQ4?RC`nn7>+G;78{7^&T2!~xRb_~U`}P|zxeQM{NT@u(_1 z1SEky(4&nUqHzSpbwjjZFFP}{9fsWe;+LTFhm^3fUg(^l_R7BQUDNqCLva!Ai7Bd%jJmOBVK88xH4>da-iLP zY4ewS6zK`dHb@Z@+z(IVHjo6N2!|l|Gvx7F2S9FnyRx)H^y~yN+hzCgbgrOgp{znj zLoGq$1+1ZpI27`WaAG?Od@`ue)^%2cTZ3})-sr9q)Cn7F60n0zHbdU`^@8S_$dwqC z@2V;X-he)BF+E?Jm7EV(87A;V;EFW8*%b!)2OS}W=Phn6iT|)zlJ^{p1yQvs%eAhAcgCXhjbJ9x*k$_QC3zw z{Jpn&@T$(KMv`#2y!oj$%xv>r`-)@oMqhqrNxk(t} zea61gz6E^EzqqyKbDU0hiv{%7zH?*qT2yZUl2eJ$-9nb7`MY3#Ckz(&Q`)pQ-WW>8 zm$b(Jr5Tuh8`4jJA_S}!0A1jJQ!^^mJXNU6l!9>h2Spwt$#+2)ZyBt~FH;wB84VUtr)c(jCULFSQsfFU>b=_KO}{4F@$(Gh#?k;1T9nyCB~5ibwsc~ z1UEHmTzE7FeVEG-Rx$EiI4uTs*sTDsC6Y&^HHLkd98I{igwEV=HHTQ9Y*Z+Jn#2(| z&HtxR_{_^ON&`rxkW2onOxQ%fRIPml zO!+|CjzeddlkUc{ct*&a9mFM^i!E4I#ND;zvy3NWC&E_f(q8d(`Ll34=5xxj zG*K*^WE6>yB2B0?6-i>uVw`h)RBTlYmn6ab{KEMB`h4#^(|o_V!@}r-b19xmTi&Bg zWpssT6WLpw!HDOr@~z@6n?3UjN=y8@_)O8V;ya~eg61f$6nJ5heVTprt*VyRhR}vM zueMeJpGJp}SM@9BL+~jIJ1j44|K;5S5NOjCfWEVrbG zz*~;slI_g4={2jdnX>u3h31TFq2yx2qM=#T63rsb5>JksWW7dsg;D;%;;#9`eA&GD zY-{O4x$F{tDPFl&PRB2g zPNA($t+*SS8%!I#tyZnP?p*Hp?v(CI_f!v2$4d;cj=Fon_$`Q$UYbCEaz?|lsN|Cs1+xW3D zurX1+it*66Hp4v@jTm3PpIz9Dfwd*R9v%CZ{-*J!iJnEQCF4@wxy?_oAUiQRB3G>k9BGr3=Ubo_GZ=t&XtLh6C$zUJQh zwCEP;s!^4+|5!P)DlwB74S}6=znYrdlIN0Dm5cakLNf5O1zf7%2FP*s~aAsA;&ZboYWO zqQ8FrlKmyuZ1UW2y@wnSQWA22%+5)Mi(i~JhkAm?Nqnn}#!AI#t~H-;8fL!HD8v9;Uyz2}+35U(W35@zE_gTe#_lHZMh;bsOh%3)T|zD+rkVOiOVM_A zwxDgqdE|CK32h>wmU4meN=aBzt-!e;QX)OpnQ?@ol}HDG*xTq`xZrFNal+6IH%!b=q~dP1YmV+3)x zde;T|9h@3$s_vs+&5(cUW=cJEjuZ|p3ARnta)xW>Y&~RMw&m(h|IT^;;i&j<3B3f} z5Q8Puso!l^300Q-XX-MYT|$Bc7un0t-@K*b*rXeD!iIZ>k%6t(`JF1(>cHL>c|;vrz+XpDE1y)w5Vw)&(bq{>v&qS1L}b?PkOtV#_+ZLyMFyUX(aSu>NVKM(WlPkX&W8WMOWEbb>0LJQu?gh0&Q8 z@waGnf-_z^&P^|(qyD`lXj&J!qRe(@s!!#$lajfk$$(5ww>WzH{$;HkGPo7V33 zY1^4IeXnwpvz{#;AMWMD1y!GsH^n`p?6HZ9{MBe}=yv;E^Wg>NC5|OJ?dX^m$1`qGm)Fb5PVr_ncbzlu7tlROm9Dif?WLUsyM4XkzD4oS=u5ss zUhfx?$9;#R_u_`)gQ@W>6@C{F?`Gr4Jj7E>_RBJ^JXb-|lLF)hfh zQ+(gqN0`wsNn=@AAgXT~3J44s8wmWH0{#X*V4VNZV!#wYp#SOz0Rjp(0|NWE%+GK9 zPmB46e{}u_gT@5`L4Kd1e1mHa$bY2ub3p$?8-DYE1eJs(CBLzffxVHDwS%dR;{nfV z{&xqot%SM*5D*6GKL9MLNOJvcf5A-om*X#487>1GD>^+x8+{`>S1a3p>;Un&a($Cl zMvi&_S1U_v2QF7$qJK$nebfJR(-Q&yCE{qoOY}=t9w2OEZvWtT1^yx=ovXVIq4ag=$V*kza?lL+^ik-TxqQx zi2ujP|Ft7x2^#8H_t;+LHFPFTTtC6L;h?&)Q%)ZCqV`O09`Ir3v$NAq8|4Z}N|7vn_{I}wN zG5%jg6$c}GVH>OO5gqydceehm{NK!fEAr6)Oj3Q>+-=QI!y@};DRpt39QxhCWk$?OaHs$Y9BvGh2oVx= z04Q`ogh-?W7?H0nz&9qg1UXdTogFfcL=q%?03<7(R5~buQVF0^Hb0z5O%8)e86Xr0 zLuAv*^GqR*U^tqAd@@@oe7iqVi5D0Yv^$eO)a-JFU!~LLdv`>?y+5Mlu8Gj%S1gyk zI}o1ye3b1|F-4Mqc%@97a8m;?iz^sK^!9l;MQ1V^4SC)2jI=)(%d27F*LbV|k)WcM zVAR70HZsVj4}4jw(i$pNDzd$zcB=nTI8dO+*tdL}?3n>NoiW7&JX+SnzqV4k>euGxy^N0;dlasoB* z4lejyAwy{->?jjWnN9I4zoh}{Cj)>ogt~AK$B>!i|*}@$j}f^HGlDNgUSR zl|N5amBcApm0f}-e$XXpc(TFeI(W1FOPITcjUw|V782Kw9zvtAgp#)hHPfXk^sq2A z?4q<0YzWj4ZkJ2lX9k;!FEirR(U1K0U~b%y&n@`D(L@XRTz$^xw`j4b=(=(YEG}0z z-59beO4vjC*$4l_(f29>+~+AA;9dq!;UKuG4kQRLH8o5yuqg2!A@CR;b^G$yH4xt=sD98`jx+?Rb9VSsJ^OBsU|_w1c7r$T zpSySnx#S5x0G1 zDi$*}3SKf9`&SGAD2mh-EfV*>SKSgbl*s4nKEym0Swp51_xodH)k@9qKO=M3+dZJb z^vtuxa?xi?Hm1$}$hsV0P<`8V2YkX^pI-B0DVzofKePGrJ%YHL%=05@Iz>se6PS#o z(k;NrWv<(UJobtS*mGplldc~h>?x26>UFjRVH^j-4j7#?==KM8gYOnyHx1%M{k~gm^oFj4ZLE>qr+8*iKN(g3mPq!^d?T7IK0mT(uz-6%krd`q9};l>^41w79(uE2UMglcpqP6~Ti>#zstQMEc1dX%Wi?z~!?#gZ`-Htyj&tjVZ;GoRriUg<6Pblbt8$x7= z1>Yx`T}D|reLmeZQ?sa?sFX`Jp)b8WN?V9#47&sGFlQR2D^_{2yY(dv1Yq?Fv3 zsGq?$n699?-5vI{)E`Z^C?RePFdYxE1`F8N{j+rZ%;KpO?0V|A!sD73#<&Qh0vb!I z><@zPaiw{I8|-Bo^II>`^Kv|!5oPsrT%MI-?&7RJGKko6&HV_R#no`%0|WaZtZ(6? zj*2e~0q|(DoVi5hfM$**4vy9z2wPZgdtwjJY>h~!*D;^2_6)Qi24oFK*U6j2PHg23 zIP`nB{m$M?IVY}~Fzp9rj7_9o=&m=Jl0Uf%D{E|Xxloa#cMIDeJ~*dwi(xV)Gd|u! z8K~?NC5Hp^r=ykK{Hl6W2&8Fh7=(tB60=U$#3tI#!^DdInt=Sh1e*&SC;@M}3?woHe`d z5Se-(j%)uJlEWX{+u5$u;lfFzQt3Xb+xsl&ACjO&)|)$@h~B&5u-}*SykgX3^WF(> zbK@gB`UNV8_dprgU$^h<6si;A%!|JEGy3Ri?JujAyB3p!TDA5Z2L`6{ z=ioy~xP!T5AJd_-^teH&To|#*WJXzL(9I$4f+msnk*CDluIQLy7o!SmfDqCg1~p*V z7C|D~U}E)P$HOlr%5aYlW%P5u+B(5}v822fcd)SJG@5{av3M9MN_NEPvYWCr-dk?8 zIWh0L)#2ngF`#_KX1SapAaIn>xG$owZMI0ZJe}hZKO@sYKQ2Il?`7WrJiBX1ti;u) zf3Rh}&4s|4cfD54dB?IHBOb#7FdR`hhGF^`;ol!@iy}c*8*LRwXt7ll4hC7@YjaM) zC@m_g6tx|_^<)td9N|II9U$oqkdaB(v*w7|f#m{zV>jLDuI=ttUq8#+WlN^gQe?Jg zA5b^lY^Vg`wX5SlUHnkY4v&Ob)~9p8c<)1!A{Z&>@p#k(?^*1KtscOF5+;d%g**HK zA$G~ov?a^$59y|V689AwsY>}Ah3^Xjeyh>8%L_-l-fkz&?)7)z+;-;RXY%2MD;*6( zPRl6ZEfJB=RY$PU*=RK7hCc(^8h69vNv*VS=&9o<$JPp!Fx)0zH%7a4hu>ziOla`x z@pr`^-2!{nY2^f(htfxj?pk&U$F6wW)r&ON(?2_hhjBs=XoDh+ziz#oQs@>JYjre2 z)RNR(Z{EQ{K#L7tSt#XWyn06$q@5=0<~=zMV~NFQ>fE>`d-#-eD;z~`2*m`jKycX2 z{_JRP5o@hFei7;^+*>`>=}~Dd+HAZY_ntEv=D5nhTcyD`bZtZtuj`sd+e&scO{D+r z?lVX^eVuXK(i+GkB}Z zlx;5Kz`|?_qp9_U_Z2GL^_Z1Q+2*=~5(M|7>aa9PISyyQD!K@QX1>D86*G={LF zy=oyF71o3WyhFmdaf?3Mw`S6;yun${8(G$uqRkG#O@hb2II&uBtFJS-Pf=jJAewx# zv%~<8c*-uYWANT=;>B{iZ8gtsYM^NHPnFe{V|O`U>9}fi!D@sw!h}K*&F*ZoeDHX1 zM(yHl5oYll&*8t~HwHPJ(y7?4D~Zs9XA@^2<~p`edire0LWgf z{vGTV+%f~2Ll&6knK{5`vn+^0r3YX=TCJ3&2KnKYHWkZ#6~@^M?aDYA3J;9(`JF@M zz}H`7LPPYSIAVCO+)%?vEKV%B6kzml`XM|{XkVaJlmVn^KL z2Nj_N7?6u3iVUjgQ;=%nlFOy}QTj4Ff;Qbj6Zm#9y5%N+s;?hbon5 zm1jk5G+9x@-5!%O7PHu4!1R&B(5&-v9M#Q2(hR)Vq7-#xfV)*IaA zLAJUY0qGWSr!TKj3yvf%^%bwW+Jvp4on}ZfUz+rnqtrV2ft?GAY6EJ6?PBh~mj;K0 z202p)VYQY}hBA*O>YaOrePx=#AgGANl|zZxBB4V5sZT z)^(o}s=z0uG8=1rs^mrRC9k#oA@$kRPwM`2B9tzAb?||V(-khJUgoJiw;NZUQ|LMq zm-PLLRu2I15^cWG@jFD-71s{;TG>r#NgUG{t^9pr&x|)juPlhQo|NRF3+KR1@_Px_ z7>2y9bsT}gD2;9W-Z0C*47@E@D<^5dVAAB?{T;Ar!>(m?%}mOGLRY=(o1#ffB|;wG zj^sZZ^JfB3sg?ma&p)x5^-p+lT#`+uGW$Yy_^>^8q$%UcqJ<;r%hccIa8SGXGm;BX zYrH?iZa&0!s?geN@huep@ZTOy6+=^I#-Do}o5^M%h2U{Jmj%>g{JpZv|E$V;DJi1DBLFgjWFnZm=y?ZofcogkAW{CpoU> zWNs$;xnQxheRM{Tj*`wIw|%*(q$7|^X)BT{vkyJ+#Qj6!a3Y0I0}C(bE#h>cTz~ip zG?w~r{iK)DHHJ(&n^KQNB2{65x^zzdJ}ZT$qxwoPqtW~LUQPm<)8(@8-O;|zzA~KKre|F%Zf```vH#<0M)wU!2=I=yHbNBV;iu`0?aFk)dH1KZJ&UUhIP>;HT-c z#-X%q1zcwepnsk|JwC#a zc?Hg9Hm|s$;V`k@SFZ&Ss(MGi`J~{d#sYyVe>l#Ybta1!;Rnyt>wA5LFnPU?lShev z{3>Jw=_Y~@1ID2Hl^0P|lt87TXwTfK=($aE;c55Ph} z(YG`+fcmWjk0)PQD|Jj=cwdU5RN|H;)bDksbFz3hOYN^|*7>xup5ZaI;&H)x(YT~- zkx3&8n_Nej>b$$3Ynb)w!D1 zYXjnFGOK=#Ra(lA!+BS?4%II1JE`9L-cBwz>!Ff~%M5+*5@P9fv|Zhkx;H!CY_B$X z+gHxv;qbVSz+utH?|T_owAx*v_k??EEmvTgTMv8hjIzXK#*sDK+~G;3(1Npl*xNS? z7}qy8@TE}oYQLWo+)9nk@b7i0dwmYl=Uq|(2@>H_g`~p&S<80>SG~@-hrSE9dskGt z)_wr9+53YlEcyD+Ul(x}t5qnd`4Q6XFz`(-Wr-xF zo(2o0la=iz92{g7khs4(RAj?`=pr2isEIN!R;dRUO7r!}@n0;^X;yvmdhMu`lWEqq zZQmA-rEu7V!Hf=&a(^d>5rlP2aI+BB#w{q-Z_rNzxPipCdoAYfo7SIX5QsniyuV~C zOl-BkaC>s>5udNW*nDVJtxJ~~o*!Lae*R&*9lkyrB5>(j8q#qy9&b)OL<7HBI7KhH zwto?!{Hz5LX{ojOqEBHv0JV(X^u<}SIS!~?Yxnp5aNk=wg6UpaQ9s%#==$=;?#Un2 z`}m6!%j@9`7Kt15c-1t+xX#R^M7)PVrx^g9i)weV>ZDw$3XUA#CodsWrrrbngkpQU zPdT239*RE3FTS41(cIFSE0w4=U0nU;-{Ieo&bS40;ST{f@EuECIizj;*!80^det>U z#F~xpE|WG7_Du{Nk6AVv!0@5^JGe$IQK8j9cw()JTVYV-$MSMAfkixT@8jL3R})#} zR$bz?Yme&VfSP37*LY*g=WB(35;OC~ zil|WJJ@lX%?6Z`JPrC5|^&3+$h{&ELi>@9exr4!i5NIia8@X0{c-j8D^_II7MlUip z%jF2>;wb+;Zmmk@PAGinPW8DRcL{?V&#uwbu0g)dC8SANxK`(LdQ&aQ(RZ zyGRFYxpK?9SiMz}6kvh~Gqy@kiAM?J2U2n`CE8%VAoN2FHG$dWXJ9-wNhK-@GLhr6 zJlyKvtBAY00*yv*NMGNpa_O-ardEY~NytQlg;Dw)h4vIznS6x{0u_~)S#!1tn4Ol> z^VIt1onV52$}ikD=Z^yF3%yRF3+ zlA_FpHH+`-m$6xB1s4*Le%?Ti$YY4Y=MCxs-BOdixh)HAQ6MMOBloJ^-&ocuh6zD zn=2A|m+}_rxn@Ctuh3i+&9&(d*hNsjLVgeeG8j3T;yN`1EUI@L5Mf7kVD|XT#}Km2 zHtq;3%x+lo4k*OCw7y~b0&@oDL+vFp?hzEO#NH$xe~wQVAqVlBXe*dtfN3078Wuyz zcM)%R2Z>|^sx?B1Y^qzl-k5sIcrvX>J2f9GSAgI)SZ1ZhJKmgH?z6-FO>cVJTPi(2 z0Exgy?yWrvog-Iwb}hmzXKlG9J{mtP?XN3rd~rY_ zreoM1=Tj!P3lb9@++9B3Oo!Rnq76a+JT4K`N=3D12S|3+r+w#gCC87n3&NBqQZ}p2;G0)3r=7_eNC`uw>c~MivYVzfn@SA!ZAW6Nb`P^_aQ1KzuZ|CM zH6ZyJSI(y8aJ4XN_pA%_ZvLg!hQ?~kv&2${1W%8s)7+|NE4n`9PzB9WAfs>L2f%dMgRy?it zwsIF!TgfPXGZZjj#(u#ozixQpxO70F$sjHmc+}uJ=bb`}J?TbxN)*e*;Y2!70dtx) z53OKhI3~S0g)dwo&Nv4mqAnfXB=%EY@4g2Y0gR-1ll+d5`P;_&IvVax5#*foIN^jM zVwZKC#c{NyI_beljtV}e3uGpjvr68xDo zQfg$k6smCLOBEv4(CEd*v8e%)5IQaP=yBE4P&QSXoCp0l#WLicOi8&&#g;#JcNo;O zW&4l~Z~f&1M5vFUNnyhMq>!FcgxZSLCXKLfiZ~6?+-3`I(w>QEU|4y>1~{8n-5swY zdE;u8y`jhF8(5(IfI=zAk2m`uz1|+?BQ$=;uw%;5ZeL4lv1b!@g|U=;6TnA>oNdR214u+tXyRRJG%`&tp2M zZ(v~e?pkrl54U&MNAu$*hthy!kCn4z@wN;Y5gCrSD)buVsd%0t)(a|n6!ql>cRcM! zDW^^OwUS@Fu^@)(WC?o>nA{7SclD3b>6TUjq`1C@R60ENvaGvBq zoYrg*<@}>8ii3KSBPWT|>-O@LjE-<9^G(k43Ar@O5gseQ;vIoVTEzBHcP)y(QC!DS z5q)+o{T;YA>)F|k1-d7TB`k0jDI=5jhDNZPO z3?Zi`+|R``;_um!K)Y~I%YK?JS3}&7p}`A`Z%|N@0G#=z{4^9g&a-S^U;bq~h?4&O zMEKrRYrm=d7t7%P_?37STR!L*h zTa>up>>6>9iUQwf-Aa_jQx|-7^OYgt+Dw7ry!MjG%*w;}VVh~e7wp7>=lTaSFu9#J zgr>2Pg1k?NMFQ%t2Db*-w=Ad2pp3gvF1n~!n=B}W?#1-%=vR{MiiyRe^3oGBaQU4< z=1fF#Ry1VMAuNi&mrQ`FPb0>sw->82v-3KIGP<5aww}Qa<`jlz?YwQ2n<|>&a2Es& zosB8~t__;M(DqYf+}>k6eA?DrD+-*}2?iMO!9+S{{gG$LsNzrk249KV*2qRd8V2Io zhK~h{cVYI|x;R)9;$4!cDFadY?@t>^*rmK!l&hN}w!}t4W#5H% z?7AIe+2?+Lj=TD+qmQx;#Tnl_Ogcc@yg_3O2iA>Sr;J%<;s>#YGLrDu;=w( zC_hvjGKG5eM1{)L@-$0AzqtW4#(r6DO-XxftF^}2S!t7E5qK7Ilipmw@$1Z_^-7(} z9SA7tsnP^%cZ5z?3faJ&tDh_7xkmHj4vy}>$7U~c}l}4xV()^ce$fc4P5(Gh?{H}W2V4KE`{&A4VvS_vEQ#Q?#Z22Yf?R zEC-uaya94B3uO-l<#W^eRf82XE2b|$Ju3Nju!t$<=y}zGVSAAV>IJWl z1?DSMv?frm(oG(biU3%Ly5NtuQ>x}K2u~UFP&4@L1y2zG&(`Nq=W6kfS0`jXMliWC zN?y)(xcVQi@0V>>b;0m+X}@}pn8jIxVf{jW^viOjeaQ^j)=2MLPi{W(YN+<)?1De5`0T<61MW0F&%M9_!zg4JY|((AdCfEkl*{cCib_%nEm(;aS)d>Qhz8q zjx>f(5VoQR4hG-PDMm?f!T054j*E~_;JZUwdG^F|zlj+k!1xdu6o?72;N;{_6#7y6 z&gZq;q{wPZ=TQ2xl%w`IQ?eUKM40JkRMeM_w`Xomq&?xj8{hV|Zj{#>)WOrzm3_hr zIAb2m#qpKjg+tqVej*NT0$!RIwcq}yFZ4*Y>Z1r17cn>W51wPoP0kBtm3TayMt`6L zVUYuis^+d~@NzLk`a~p&PiU0TF@=|(B&x*tcc$f3r|iN!!n=4+NtpmC(iOoe(Z zrqmKYJOEWebtVv92_5U%goBa&nEn0%y!6KmlQI%Xh|EDS$PPD@wl?cQV7FLKP@Ut3 z9GepU>mhwe{}?)Th+q<9Z}&YLOj?-jvn?DptEbZK)g{*JCxB5Qu~E}|qt%8K(4sQM z579N$0aL+Z)C?HkSEW7LDe(-vP4hBvPx#UOUB=FJwL=W`3qgARLflEcIW!M6h_*Av z%~n8Kx9u=zoi9KGpYV7iB^*H49ZWAzB_^)&Ne95$1;w^2TDUpIFBzSMp5!&1hBFZ% z9cG$Q$uRGY+ng;hDC&*k_t$WP#(c&8)%g*p3zjr6&L@~4Q78I6zN>* zsH3xUY(oi-l)JkWuz;j%((Szd@%zi&+1msSuo3@Z*`D>*%u@IX#N1PKXlPIFmVU!+ zM(}_u^ayiinu7C}Uc*XTwi&n{;>y>Nha6a}WWk#V(m$9ZMsnJ( - ['@/assets/*.png', '@/assets/custom-logos/*.png'], - { - eager: true - } -); - -/** - * Extract filename from glob import path - * Converts '/src/assets/neuroglancer.png' to 'neuroglancer.png' - */ -function extractFileName(path: string): string { - const parts = path.split('/'); - return parts[parts.length - 1]; -} - -/** - * Get logo path for a viewer - * Logo resolution order: - * 1. If customLogoPath is provided, use that from @/assets/ or @/assets/custom-logos/ - * 2. If not, try to load {viewerName}.png from @/assets/ or @/assets/custom-logos/ - * 3. If not found, use fallback logo - * - * @param viewerName - Name of the viewer (case-insensitive) - * @param customLogoPath - Optional custom logo filename from config (e.g., "my-logo.png") - * @returns Logo path to use - */ -export function getViewerLogo( - viewerName: string, - customLogoPath?: string -): string { - const logoFileName = customLogoPath || `${viewerName.toLowerCase()}.png`; - - // Search through available logos - for (const [path, module] of Object.entries(LOGO_MODULES)) { - const fileName = extractFileName(path); - if (fileName === logoFileName) { - return module.default; - } - } - - // If logo not found, return fallback - return fallback_logo; -} diff --git a/frontend/src/config/viewersConfig.ts b/frontend/src/config/viewersConfig.ts index 0f305a09a..a5783c71b 100644 --- a/frontend/src/config/viewersConfig.ts +++ b/frontend/src/config/viewersConfig.ts @@ -17,8 +17,7 @@ const ViewerConfigEntrySchema = z.object( instance_template_url: z .string({ message: '"instance_template_url" must be a string' }) .optional(), - label: z.string({ message: '"label" must be a string' }).optional(), - logo: z.string({ message: '"logo" must be a string' }).optional() + label: z.string({ message: '"label" must be a string' }).optional() }, { error: iss => { From 36e9f059f82e65645ad29fc18c541b0245381571 Mon Sep 17 00:00:00 2001 From: Allison Truhlar Date: Tue, 14 Apr 2026 20:24:46 +0000 Subject: [PATCH 087/104] refactor: point manifest URLs to capability-manifest repo --- frontend/public/viewers/avivator.yaml | 41 ----------------------- frontend/public/viewers/neuroglancer.yaml | 41 ----------------------- frontend/public/viewers/validator.yaml | 41 ----------------------- frontend/public/viewers/vole.yaml | 41 ----------------------- frontend/src/config/viewers.config.yaml | 10 +++--- 5 files changed, 4 insertions(+), 170 deletions(-) delete mode 100644 frontend/public/viewers/avivator.yaml delete mode 100644 frontend/public/viewers/neuroglancer.yaml delete mode 100644 frontend/public/viewers/validator.yaml delete mode 100644 frontend/public/viewers/vole.yaml diff --git a/frontend/public/viewers/avivator.yaml b/frontend/public/viewers/avivator.yaml deleted file mode 100644 index 3098d3906..000000000 --- a/frontend/public/viewers/avivator.yaml +++ /dev/null @@ -1,41 +0,0 @@ -viewer: - name: "Avivator" - version: "0.16.1" - repo: "https://github.com/hms-dbmi/viv" - template_url: "https://avivator.gehlenborglab.org/?image_url={DATA_URL}" - -capabilities: - # Enumeration of OME-Zarr versions that can be loaded - ome_zarr_versions: [0.4] - - compression_codecs: ["blosc", "gzip"] - - # Which additional RFCs are supported? - rfcs_supported: [] - - # Are axis names and units respected? - axes: true - - # Are scaling factors respected on multiscales? - scale: true - - # Are translation factors respected on multiscales (including subpixel offsets for lower scale levels)? - translation: true - - # Does the tool support multiple channels? - channels: true - - # Does the tool support multiple timepoints? - timepoints: true - - # Are labels loaded when available? - labels: false - - # Are HCS plates loaded when available? - hcs_plates: false - - # Does the viewer handle multiple images in a bioformats2raw layout? - bioformats2raw_layout: false - - # Is the OMERO metadata used to e.g. color the channels? - omero_metadata: true diff --git a/frontend/public/viewers/neuroglancer.yaml b/frontend/public/viewers/neuroglancer.yaml deleted file mode 100644 index 4ccebd234..000000000 --- a/frontend/public/viewers/neuroglancer.yaml +++ /dev/null @@ -1,41 +0,0 @@ -viewer: - name: "Neuroglancer" - version: "2.41.2" - repo: "https://github.com/google/neuroglancer" - template_url: https://neuroglancer-demo.appspot.com/#!{"layers":[{"name":"image","source":"{DATA_URL}","type":"image"}]} - -capabilities: - # Enumeration of OME-Zarr versions that can be loaded - ome_zarr_versions: [0.4, 0.5] - - compression_codecs: ["blosc", "zstd", "zlib", "lz4", "gzip"] - - # Which additional RFCs are supported? - rfcs_supported: [] - - # Are axis names and units respected? - axes: true - - # Are scaling factors respected on multiscales? - scale: true - - # Are translation factors respected on multiscales (including subpixel offsets for lower scale levels)? - translation: true - - # Does the tool support multiple channels? - channels: true - - # Does the tool support multiple timepoints? - timepoints: true - - # Are labels loaded when available? - labels: false - - # Are HCS plates loaded when available? - hcs_plates: false - - # Does the viewer handle multiple images in a bioformats2raw layout? - bioformats2raw_layout: false - - # Is the OMERO metadata used to e.g. color the channels? - omero_metadata: true diff --git a/frontend/public/viewers/validator.yaml b/frontend/public/viewers/validator.yaml deleted file mode 100644 index b12987a73..000000000 --- a/frontend/public/viewers/validator.yaml +++ /dev/null @@ -1,41 +0,0 @@ -viewer: - name: 'OME-Zarr Validator' - version: '1.0.0' - repo: 'https://github.com/ome/ome-ngff-validator' - template_url: 'https://ome.github.io/ome-ngff-validator/?source={DATA_URL}' - -capabilities: - # Enumeration of OME-Zarr versions that can be loaded - ome_zarr_versions: [0.4, 0.5] - - compression_codecs: [] - - # Which additional RFCs are supported? - rfcs_supported: [] - - # Are axis names and units respected? - axes: true - - # Are scaling factors respected on multiscales? - scale: true - - # Are translation factors respected on multiscales (including subpixel offsets for lower scale levels)? - translation: true - - # Does the tool support multiple channels? - channels: true - - # Does the tool support multiple timepoints? - timepoints: true - - # Are labels loaded when available? - labels: true - - # Are HCS plates loaded when available? - hcs_plates: true - - # Does the viewer handle multiple images in a bioformats2raw layout? - bioformats2raw_layout: false - - # Is the OMERO metadata used to e.g. color the channels? - omero_metadata: true diff --git a/frontend/public/viewers/vole.yaml b/frontend/public/viewers/vole.yaml deleted file mode 100644 index fdd678036..000000000 --- a/frontend/public/viewers/vole.yaml +++ /dev/null @@ -1,41 +0,0 @@ -viewer: - name: 'Vol-E' - version: '1.0.0' - repo: 'https://github.com/allen-cell-animated/volume-viewer' - template_url: 'https://volumeviewer.allencell.org/viewer?url={DATA_URL}' - -capabilities: - # Enumeration of OME-Zarr versions that can be loaded - ome_zarr_versions: [0.4, 0.5] - - compression_codecs: [] - - # Which additional RFCs are supported? - rfcs_supported: [] - - # Are axis names and units respected? - axes: true - - # Are scaling factors respected on multiscales? - scale: true - - # Are translation factors respected on multiscales (including subpixel offsets for lower scale levels)? - translation: true - - # Does the tool support multiple channels? - channels: true - - # Does the tool support multiple timepoints? - timepoints: true - - # Are labels loaded when available? - labels: false - - # Are HCS plates loaded when available? - hcs_plates: false - - # Does the viewer handle multiple images in a bioformats2raw layout? - bioformats2raw_layout: false - - # Is the OMERO metadata used to e.g. color the channels? - omero_metadata: false diff --git a/frontend/src/config/viewers.config.yaml b/frontend/src/config/viewers.config.yaml index 8c5b8beaf..8a0400bd4 100644 --- a/frontend/src/config/viewers.config.yaml +++ b/frontend/src/config/viewers.config.yaml @@ -13,15 +13,13 @@ # # Optional: # - instance_template_url: Override the viewer's template_url from the manifest -# - logo: Filename of logo in frontend/src/assets/ or frontend/src/assets/custom-logos/ -# (defaults to {normalized_name}.png; custom-logos/ is gitignored for local overrides) # - label: Custom tooltip text (defaults to "View in {Name}") viewers: - - manifest_url: 'https://raw.githubusercontent.com/JaneliaSciComp/fileglancer/viewers-config/frontend/public/viewers/neuroglancer.yaml' + - manifest_url: 'https://raw.githubusercontent.com/BioImageTools/capability-manifest/host-manifests-and-docs/manifests/neuroglancer.yaml' - - manifest_url: 'https://raw.githubusercontent.com/JaneliaSciComp/fileglancer/viewers-config/frontend/public/viewers/avivator.yaml' + - manifest_url: 'https://raw.githubusercontent.com/BioImageTools/capability-manifest/host-manifests-and-docs/manifests/avivator.yaml' - - manifest_url: 'https://raw.githubusercontent.com/JaneliaSciComp/fileglancer/viewers-config/frontend/public/viewers/validator.yaml' + - manifest_url: 'https://raw.githubusercontent.com/BioImageTools/capability-manifest/host-manifests-and-docs/manifests/validator.yaml' - - manifest_url: 'https://raw.githubusercontent.com/JaneliaSciComp/fileglancer/viewers-config/frontend/public/viewers/vole.yaml' + - manifest_url: 'https://raw.githubusercontent.com/BioImageTools/capability-manifest/host-manifests-and-docs/manifests/vole.yaml' From 1979ddf93c5dbba740b32c71e31beda6ca8b3790 Mon Sep 17 00:00:00 2001 From: Allison Truhlar Date: Tue, 14 Apr 2026 20:25:32 +0000 Subject: [PATCH 088/104] docs: update ViewersConfiguration for capability-manifest ownership --- docs/ViewersConfiguration.md | 61 ++++++++++-------------------------- 1 file changed, 16 insertions(+), 45 deletions(-) diff --git a/docs/ViewersConfiguration.md b/docs/ViewersConfiguration.md index cad642f0b..17b7e9df2 100644 --- a/docs/ViewersConfiguration.md +++ b/docs/ViewersConfiguration.md @@ -45,7 +45,6 @@ The configuration file has a single top-level key, `viewers`, containing a list | `manifest_url` | Yes | URL to a capability manifest YAML file | | `instance_template_url` | No | Override the viewer's `template_url` from the manifest | | `label` | No | Custom tooltip text (defaults to "View in {Name}") | -| `logo` | No | Filename of logo in `frontend/src/assets/` or `frontend/src/assets/custom-logos/` (defaults to `{normalized_name}.png`) | ### Default Configuration @@ -53,21 +52,21 @@ The default `viewers.config.yaml` configures four viewers: ```yaml viewers: - - manifest_url: "https://raw.githubusercontent.com/JaneliaSciComp/fileglancer/main/frontend/public/viewers/neuroglancer.yaml" + - manifest_url: "https://raw.githubusercontent.com/BioImageTools/capability-manifest/host-manifests-and-docs/manifests/neuroglancer.yaml" - - manifest_url: "https://raw.githubusercontent.com/JaneliaSciComp/fileglancer/main/frontend/public/viewers/avivator.yaml" + - manifest_url: "https://raw.githubusercontent.com/BioImageTools/capability-manifest/host-manifests-and-docs/manifests/avivator.yaml" instance_template_url: "https://janeliascicomp.github.io/viv/?image_url={DATA_URL}" - - manifest_url: "https://raw.githubusercontent.com/JaneliaSciComp/fileglancer/main/frontend/public/viewers/validator.yaml" + - manifest_url: "https://raw.githubusercontent.com/BioImageTools/capability-manifest/host-manifests-and-docs/manifests/validator.yaml" label: "View in OME-Zarr Validator" - - manifest_url: "https://raw.githubusercontent.com/JaneliaSciComp/fileglancer/main/frontend/public/viewers/vole.yaml" + - manifest_url: "https://raw.githubusercontent.com/BioImageTools/capability-manifest/host-manifests-and-docs/manifests/vole.yaml" label: "View in Vol-E" ``` ## Capability Manifest Files -Manifest files describe a viewer's identity and capabilities. The default manifests are stored in `frontend/public/viewers/` and are hosted via GitHub. You can host your own manifest files anywhere accessible via URL. +Manifest files describe a viewer's identity and capabilities. The default manifests are hosted in the [`@bioimagetools/capability-manifest`](https://github.com/BioImageTools/capability-manifest) repository. You can host your own manifest files anywhere accessible via URL. ### Manifest Structure @@ -145,7 +144,7 @@ https://neuroglancer-demo.appspot.com/#!{"layers":[{"name":"image","source":"htt ```yaml viewers: - - manifest_url: "https://raw.githubusercontent.com/JaneliaSciComp/fileglancer/main/frontend/public/viewers/neuroglancer.yaml" + - manifest_url: "https://raw.githubusercontent.com/BioImageTools/capability-manifest/host-manifests-and-docs/manifests/neuroglancer.yaml" ``` ### Override a viewer's URL @@ -154,9 +153,9 @@ Use `instance_template_url` to point to a custom deployment of a viewer while st ```yaml viewers: - - manifest_url: "https://raw.githubusercontent.com/JaneliaSciComp/fileglancer/main/frontend/public/viewers/avivator.yaml" + - manifest_url: "https://raw.githubusercontent.com/BioImageTools/capability-manifest/host-manifests-and-docs/manifests/avivator.yaml" instance_template_url: "https://my-avivator-instance.example.com/?image_url={dataLink}" - logo: avivator.png + ``` ### Add a custom viewer @@ -187,7 +186,7 @@ capabilities: omero_metadata: false ``` -2. Host the manifest at an accessible URL (e.g., in your own `frontend/public/viewers/` directory, on GitHub, or any web server). +2. Host the manifest at an accessible URL (e.g., on GitHub or any web server). 3. Reference it in `viewers.config.yaml`: @@ -197,7 +196,7 @@ viewers: label: "Open in My Viewer" ``` -4. Optionally, add a logo file at `frontend/src/assets/custom-logos/myviewer.png` (the normalized name, lowercase with non-alphanumeric characters removed). Using `custom-logos/` keeps your additions gitignored. +4. Optionally, add a `logo` field to the viewer's capability manifest pointing to a hosted logo image. If no logo is specified, one is derived by convention from the viewer name. If the logo fails to load, a fallback image is shown. ## How Compatibility Works @@ -210,41 +209,13 @@ The `@bioimagetools/capability-manifest` library handles all compatibility check This replaces the previous system where `valid_ome_zarr_versions` was a global config setting and custom viewers used simple version matching. Now all compatibility logic is driven by the detailed capabilities declared in each viewer's manifest. -## Adding Custom Viewer Logos - -Logo resolution follows this order: - -1. **Custom logo specified**: If you provide a `logo` field in the config entry, that filename is looked up in `frontend/src/assets/` and `frontend/src/assets/custom-logos/` -2. **Convention-based**: If no `logo` is specified, the system looks for `{normalized_name}.png` in both directories, where the normalized name is the viewer's name lowercased with non-alphanumeric characters removed -3. **Fallback**: If neither is found, `frontend/src/assets/fallback_logo.png` is used - -### Custom logos without git changes - -To add logos without modifying tracked files, place them in `frontend/src/assets/custom-logos/`. This directory is gitignored, so your logos won't trigger git changes or conflict with upstream updates. - -1. Create the directory: `mkdir -p frontend/src/assets/custom-logos/` -2. Place your logo PNG files there (e.g., `frontend/src/assets/custom-logos/myviewer.png`) -3. Rebuild the application: `pixi run node-build` - -Logos in `custom-logos/` are resolved the same way as logos in `assets/` — by convention-based naming or by the `logo` field in the config. - -### Examples +## Viewer Logos -**Using the naming convention (recommended):** +Viewer logos are managed by the `@bioimagetools/capability-manifest` library. Logo resolution follows this order: -```yaml -viewers: - - manifest_url: "https://example.com/manifests/neuroglancer.yaml" - # Logo automatically resolves to neuroglancer.png in assets/ or assets/custom-logos/ -``` - -**Using a custom logo filename:** - -```yaml -viewers: - - manifest_url: "https://example.com/manifests/avivator.yaml" - logo: "avivator.png" # Looked up in assets/ and assets/custom-logos/ -``` +1. **Override**: If the manifest includes a `viewer.logo` field, that URL is used directly +2. **Convention-based**: Otherwise, the logo URL is derived from the viewer name (lowercased, spaces replaced with hyphens, e.g. "OME-Zarr Validator" → `ome-zarr-validator.png`) and hosted alongside the manifests +3. **Fallback**: If the logo fails to load at runtime, a bundled fallback image is shown ## Development @@ -261,7 +232,7 @@ The configuration is validated at build time using Zod schemas (see `frontend/sr - The `viewers` array must contain at least one entry - Each entry must have a valid `manifest_url` (a properly formed URL) -- Optional fields (`instance_template_url`, `label`, `logo`) must be strings if present +- Optional fields (`instance_template_url`, `label`) must be strings if present At runtime, manifests that fail to load are skipped with a warning. If a viewer has no `template_url` (neither from its manifest nor from `instance_template_url` in the config), it is also skipped. From 78eca390a3dd80f028f2512ebf443d4b55bb0504 Mon Sep 17 00:00:00 2001 From: Allison Truhlar Date: Tue, 14 Apr 2026 20:25:35 +0000 Subject: [PATCH 089/104] docs: remove logo override from CLAUDE.md viewers config description --- CLAUDE.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index 5a359710c..eb6b27456 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -173,7 +173,7 @@ Key settings: Fileglancer uses a manifest-based viewer configuration system. Each viewer is defined by a **capability manifest** (a YAML file describing the viewer's name, URL template, and capabilities). The config file lists manifest URLs and optional overrides. -- **Configuration file**: `frontend/src/config/viewers.config.yaml` -- lists viewers by `manifest_url` with optional `instance_template_url`, `label`, and `logo` overrides +- **Configuration file**: `frontend/src/config/viewers.config.yaml` -- lists viewers by `manifest_url` with optional `instance_template_url` and `label` overrides - **Manifest files**: `frontend/public/viewers/*.yaml` -- capability manifest YAML files defining each viewer's identity and supported features - **Compatibility**: Handled by the `@bioimagetools/capability-manifest` library, which checks dataset metadata against manifest capabilities at runtime - **Documentation**: See `docs/ViewersConfiguration.md` From 100d40387f25454505dcfc6fb6c29d73d2f09ab5 Mon Sep 17 00:00:00 2001 From: Allison Truhlar Date: Tue, 14 Apr 2026 20:44:01 +0000 Subject: [PATCH 090/104] fix: update DataToolLinks test mocks to match current API The validateViewer mock was using the old `compatible` field instead of `dataCompatible`/`dataFeaturesSupported`, causing tests to pass for the wrong reason. Also adds missing `getLogoUrl` mock to prevent undefined function errors. --- .../componentTests/DataToolLinks.test.tsx | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/frontend/src/__tests__/componentTests/DataToolLinks.test.tsx b/frontend/src/__tests__/componentTests/DataToolLinks.test.tsx index 93421d147..1098b7d2f 100644 --- a/frontend/src/__tests__/componentTests/DataToolLinks.test.tsx +++ b/frontend/src/__tests__/componentTests/DataToolLinks.test.tsx @@ -19,7 +19,11 @@ vi.mock('@/logger', () => ({ // Mock capability manifest to avoid network requests in tests const mockCapabilityManifest = vi.hoisted(() => ({ loadManifestsFromUrls: vi.fn(), - validateViewer: vi.fn() + validateViewer: vi.fn(), + getLogoUrl: vi.fn( + (manifest: { viewer: { name: string } }) => + `https://icons.example.com/${manifest.viewer.name.toLowerCase().replace(/\s+/g, '-')}.png` + ) })); vi.mock('@bioimagetools/capability-manifest', () => mockCapabilityManifest); @@ -68,7 +72,8 @@ describe('DataToolLinks - Error Scenarios', () => { // Default mock: return empty Map (no manifests loaded) mockCapabilityManifest.loadManifestsFromUrls.mockResolvedValue(new Map()); mockCapabilityManifest.validateViewer.mockReturnValue({ - compatible: false, + dataCompatible: false, + dataFeaturesSupported: false, errors: [ { capability: 'test', @@ -179,7 +184,8 @@ describe('DataToolLinks - Edge Cases', () => { // Mock validateViewer to return compatible for all viewers mockCapabilityManifest.validateViewer.mockReturnValue({ - compatible: true, + dataCompatible: true, + dataFeaturesSupported: true, errors: [], warnings: [] }); @@ -294,7 +300,8 @@ describe('DataToolLinks - Expected Behavior', () => { // Mock validateViewer to return compatible for all viewers mockCapabilityManifest.validateViewer.mockReturnValue({ - compatible: true, + dataCompatible: true, + dataFeaturesSupported: true, errors: [], warnings: [] }); From ab32fdb5c9cf56f0e319bbcf6e9b83c9627a930b Mon Sep 17 00:00:00 2001 From: Allison Truhlar Date: Tue, 14 Apr 2026 20:44:19 +0000 Subject: [PATCH 091/104] fix: replace as any cast with proper OmeZarrMetadata typing Use explicit OmeZarrMetadata type annotation with targeted field casts instead of a blanket `as any`, so type mismatches with the capability-manifest library will be caught at compile time. --- frontend/src/hooks/useZarrMetadata.ts | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/frontend/src/hooks/useZarrMetadata.ts b/frontend/src/hooks/useZarrMetadata.ts index 22b26a252..63e58b28d 100644 --- a/frontend/src/hooks/useZarrMetadata.ts +++ b/frontend/src/hooks/useZarrMetadata.ts @@ -5,6 +5,7 @@ import { usePreferencesContext } from '@/contexts/PreferencesContext'; import { useProxiedPathContext } from '@/contexts/ProxiedPathContext'; import { useExternalBucketContext } from '@/contexts/ExternalBucketContext'; import { useViewersContext } from '@/contexts/ViewersContext'; +import type { OmeZarrMetadata } from '@bioimagetools/capability-manifest'; import { useZarrMetadataQuery, useOmeZarrThumbnailQuery, @@ -113,15 +114,17 @@ export default function useZarrMetadata() { // If we have multiscales metadata (OME-Zarr), use capability checking to filter if (metadata?.multiscale) { // Convert our metadata to OmeZarrMetadata format for capability checking - const omeZarrMetadata = { + const omeZarrMetadata: OmeZarrMetadata = { version: zarrMetadataQuery.data?.availableOmeZarrVersions.sort( (a, b) => parseFloat(b) - parseFloat(a) )[0], - axes: metadata.multiscale?.axes, - multiscales: metadata.multiscale ? [metadata.multiscale] : undefined, - omero: metadata.omero, + axes: metadata.multiscale?.axes as OmeZarrMetadata['axes'], + multiscales: metadata.multiscale + ? ([metadata.multiscale] as OmeZarrMetadata['multiscales']) + : undefined, + omero: metadata.omero as OmeZarrMetadata['omero'], labels: metadata.labels - } as any; // Type assertion needed due to internal type differences + }; compatibleViewers = getCompatibleViewers(omeZarrMetadata); From 19268830466d1b3fc86b5f6c5c9d0e52ae2a227d Mon Sep 17 00:00:00 2001 From: Allison Truhlar Date: Tue, 14 Apr 2026 20:44:35 +0000 Subject: [PATCH 092/104] fix: use hyphens in normalizeViewerName to avoid key collisions Replace non-alphanumeric stripping with hyphen substitution, matching the convention used by getLogoUrl. Prevents silent key collisions for names like "Vol-E" vs "Vol E". --- frontend/src/contexts/ViewersContext.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/frontend/src/contexts/ViewersContext.tsx b/frontend/src/contexts/ViewersContext.tsx index 7119f3bc5..4ecd4fc65 100644 --- a/frontend/src/contexts/ViewersContext.tsx +++ b/frontend/src/contexts/ViewersContext.tsx @@ -55,9 +55,6 @@ async function loadViewersConfig(): Promise { try { const module = await import('@/config/viewers.config.yaml?raw'); configYaml = module.default; - log.info( - 'Using custom viewers configuration from src/config/viewers.config.yaml' - ); } catch (error) { throw new Error( `Failed to load viewers configuration: ${error instanceof Error ? error.message : 'Unknown error'}` @@ -78,7 +75,10 @@ async function loadViewersConfig(): Promise { * Normalize viewer name to a valid key */ function normalizeViewerName(name: string): string { - return name.toLowerCase().replace(/[^a-z0-9]/g, ''); + return name + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-|-$/g, ''); } export function ViewersProvider({ From 29d878211e07b294566a2e3f4e41d0cfae854637 Mon Sep 17 00:00:00 2001 From: Allison Truhlar Date: Tue, 14 Apr 2026 20:44:49 +0000 Subject: [PATCH 093/104] docs: match default config example to actual viewers.config.yaml The docs showed instance_template_url and label overrides in the default config example, but the actual committed config has bare manifest_url entries only. --- docs/ViewersConfiguration.md | 3 --- 1 file changed, 3 deletions(-) diff --git a/docs/ViewersConfiguration.md b/docs/ViewersConfiguration.md index 17b7e9df2..604774e69 100644 --- a/docs/ViewersConfiguration.md +++ b/docs/ViewersConfiguration.md @@ -55,13 +55,10 @@ viewers: - manifest_url: "https://raw.githubusercontent.com/BioImageTools/capability-manifest/host-manifests-and-docs/manifests/neuroglancer.yaml" - manifest_url: "https://raw.githubusercontent.com/BioImageTools/capability-manifest/host-manifests-and-docs/manifests/avivator.yaml" - instance_template_url: "https://janeliascicomp.github.io/viv/?image_url={DATA_URL}" - manifest_url: "https://raw.githubusercontent.com/BioImageTools/capability-manifest/host-manifests-and-docs/manifests/validator.yaml" - label: "View in OME-Zarr Validator" - manifest_url: "https://raw.githubusercontent.com/BioImageTools/capability-manifest/host-manifests-and-docs/manifests/vole.yaml" - label: "View in Vol-E" ``` ## Capability Manifest Files From 1e46b40f5f32e9e29ee81c877a383a971e3ce8c9 Mon Sep 17 00:00:00 2001 From: Allison Truhlar Date: Tue, 14 Apr 2026 20:45:03 +0000 Subject: [PATCH 094/104] fix: add 10s timeout for manifest loading to prevent hanging Race loadManifestsFromUrls against a timeout promise so the viewer initialization fails fast when manifest URLs are unreachable. --- frontend/src/contexts/ViewersContext.tsx | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/frontend/src/contexts/ViewersContext.tsx b/frontend/src/contexts/ViewersContext.tsx index 4ecd4fc65..a046b2cce 100644 --- a/frontend/src/contexts/ViewersContext.tsx +++ b/frontend/src/contexts/ViewersContext.tsx @@ -102,10 +102,20 @@ export function ViewersProvider({ // Extract manifest URLs const manifestUrls = configEntries.map(entry => entry.manifest_url); - // Load capability manifests + // Load capability manifests (with a 10s timeout to avoid hanging on unreachable URLs) let manifestsMap: Map; try { - manifestsMap = await loadManifestsFromUrls(manifestUrls); + const manifestsPromise = loadManifestsFromUrls(manifestUrls); + const timeoutPromise = new Promise((_, reject) => + setTimeout( + () => reject(new Error('Manifest loading timed out after 10s')), + 10_000 + ) + ); + manifestsMap = await Promise.race([ + manifestsPromise, + timeoutPromise + ]); log.info(`Loaded ${manifestsMap.size} viewer capability manifests`); } catch (manifestError) { throw new Error( From f8d9ebba61a95229d4898461fe96b22534086a5b Mon Sep 17 00:00:00 2001 From: Allison Truhlar Date: Tue, 14 Apr 2026 20:45:26 +0000 Subject: [PATCH 095/104] fix: add URL validation for instance_template_url in config schema Apply the same URL/absolute-path validation to instance_template_url that manifest_url already has, catching invalid values at parse time instead of at runtime. Updates test to expect empty strings to be rejected. --- .../__tests__/unitTests/viewersConfig.test.ts | 20 ++++++++++++++----- frontend/src/config/viewersConfig.ts | 4 ++++ 2 files changed, 19 insertions(+), 5 deletions(-) diff --git a/frontend/src/__tests__/unitTests/viewersConfig.test.ts b/frontend/src/__tests__/unitTests/viewersConfig.test.ts index d4b87d26d..e0f137c1e 100644 --- a/frontend/src/__tests__/unitTests/viewersConfig.test.ts +++ b/frontend/src/__tests__/unitTests/viewersConfig.test.ts @@ -332,20 +332,30 @@ viewers: }); it('should handle empty optional strings', () => { - const yaml = ` + // Empty label is allowed (it's a display string) + const yamlValid = ` viewers: - manifest_url: https://example.com/viewer.yaml label: "" - instance_template_url: "" `; - const result = parseViewersConfig(yaml); + const result = parseViewersConfig(yamlValid); expect(result.viewers[0]).toEqual({ manifest_url: 'https://example.com/viewer.yaml', - label: '', - instance_template_url: '' + label: '' }); + + // Empty instance_template_url is rejected (must be a valid URL or path) + const yamlInvalid = ` +viewers: + - manifest_url: https://example.com/viewer.yaml + instance_template_url: "" +`; + + expect(() => parseViewersConfig(yamlInvalid)).toThrow( + /"instance_template_url" must be a valid URL or an absolute path starting with \// + ); }); }); }); diff --git a/frontend/src/config/viewersConfig.ts b/frontend/src/config/viewersConfig.ts index a5783c71b..e9d77bc9a 100644 --- a/frontend/src/config/viewersConfig.ts +++ b/frontend/src/config/viewersConfig.ts @@ -16,6 +16,10 @@ const ViewerConfigEntrySchema = z.object( }), instance_template_url: z .string({ message: '"instance_template_url" must be a string' }) + .refine(val => val.startsWith('/') || URL.canParse(val), { + message: + '"instance_template_url" must be a valid URL or an absolute path starting with /' + }) .optional(), label: z.string({ message: '"label" must be a string' }).optional() }, From a6b9926200efdf2270ded1e38c43c4073f655d67 Mon Sep 17 00:00:00 2001 From: Allison Truhlar Date: Tue, 14 Apr 2026 16:50:22 -0400 Subject: [PATCH 096/104] refactor: rename getCompatibleViewers to getViewersCompatibleWith Avoids name collision with the library's exported getCompatibleViewers from capability-mainfest --- frontend/src/contexts/ViewersContext.tsx | 16 +++++++++------- frontend/src/hooks/useZarrMetadata.ts | 6 +++--- 2 files changed, 12 insertions(+), 10 deletions(-) diff --git a/frontend/src/contexts/ViewersContext.tsx b/frontend/src/contexts/ViewersContext.tsx index a046b2cce..236e87f6a 100644 --- a/frontend/src/contexts/ViewersContext.tsx +++ b/frontend/src/contexts/ViewersContext.tsx @@ -41,7 +41,7 @@ interface ViewersContextType { validViewers: ValidViewer[]; isInitialized: boolean; error: string | null; - getCompatibleViewers: (metadata: OmeZarrMetadata) => ValidViewer[]; + getViewersCompatibleWithImage: (metadata: OmeZarrMetadata) => ValidViewer[]; } const ViewersContext = createContext(undefined); @@ -112,10 +112,7 @@ export function ViewersProvider({ 10_000 ) ); - manifestsMap = await Promise.race([ - manifestsPromise, - timeoutPromise - ]); + manifestsMap = await Promise.race([manifestsPromise, timeoutPromise]); log.info(`Loaded ${manifestsMap.size} viewer capability manifests`); } catch (manifestError) { throw new Error( @@ -198,7 +195,7 @@ export function ViewersProvider({ initialize(); }, []); - const getCompatibleViewers = useCallback( + const getViewersCompatibleWithImage = useCallback( (metadata: OmeZarrMetadata): ValidViewer[] => { if (!isInitialized || !metadata) { return []; @@ -224,7 +221,12 @@ export function ViewersProvider({ return ( {children} diff --git a/frontend/src/hooks/useZarrMetadata.ts b/frontend/src/hooks/useZarrMetadata.ts index 63e58b28d..f73b03f28 100644 --- a/frontend/src/hooks/useZarrMetadata.ts +++ b/frontend/src/hooks/useZarrMetadata.ts @@ -37,7 +37,7 @@ export default function useZarrMetadata() { const { validViewers, isInitialized: viewersInitialized, - getCompatibleViewers + getViewersCompatibleWithImage } = useViewersContext(); // Fetch Zarr metadata @@ -126,7 +126,7 @@ export default function useZarrMetadata() { labels: metadata.labels }; - compatibleViewers = getCompatibleViewers(omeZarrMetadata); + compatibleViewers = getViewersCompatibleWithImage(omeZarrMetadata); // Create a Set for lookup of compatible viewer keys // Needed to mark incompatible but valid (as defined by the viewer config) viewers as null in openWithToolUrls @@ -245,7 +245,7 @@ export default function useZarrMetadata() { zarrMetadataQuery.data?.availableOmeZarrVersions, validViewers, viewersInitialized, - getCompatibleViewers + getViewersCompatibleWithImage ]); return { From 6c498621de0626908c39b480a1f6a524cefd4cea Mon Sep 17 00:00:00 2001 From: Allison Truhlar Date: Wed, 15 Apr 2026 11:06:01 -0400 Subject: [PATCH 097/104] docs: update docs to reflect that viewer manifests are not managed by fileglancer --- docs/Development.md | 4 +- docs/ViewersConfiguration.md | 102 ++--------------------------------- 2 files changed, 7 insertions(+), 99 deletions(-) diff --git a/docs/Development.md b/docs/Development.md index 1918d8e98..117b52162 100644 --- a/docs/Development.md +++ b/docs/Development.md @@ -70,9 +70,9 @@ Instead of using the `file_share_mounts` setting, you can configure file share p ### Viewers Configuration -Fileglancer supports dynamic configuration of OME-Zarr viewers through `viewers.config.yaml`. This allows you to customize which viewers are available in your deployment and configure custom viewer URLs. +Fileglancer supports dynamic configuration of OME-Zarr viewers through `viewers.config.yaml`. This allows you to customize which viewers are available in your deployment and configure custom viewer URLs. No configuration is required to use the default viewers defined in `frontend/src/config/viewers.config.yaml`. -**Quick Setup:** +**To customize viewers:** 1. Copy the default config: `cp frontend/src/config/viewers.config.yaml frontend/viewers.config.yaml` diff --git a/docs/ViewersConfiguration.md b/docs/ViewersConfiguration.md index 604774e69..55b7a9353 100644 --- a/docs/ViewersConfiguration.md +++ b/docs/ViewersConfiguration.md @@ -13,7 +13,9 @@ The viewer system is built on capability manifests: Each viewer is defined by a **capability manifest** hosted at a URL. The configuration file simply lists manifest URLs and optional overrides. At runtime, the manifests are fetched, and the `@bioimagetools/capability-manifest` library determines which viewers are compatible with a given dataset based on the manifest's declared capabilities. -## Quick Start +## Customize Viewers + +**Note:** No configuration is required to use the default viewers defined in `frontend/src/config/viewers.config.yaml`. 1. Copy the default config: `cp frontend/src/config/viewers.config.yaml frontend/viewers.config.yaml` 2. Edit `frontend/viewers.config.yaml` to customize viewers @@ -63,77 +65,7 @@ viewers: ## Capability Manifest Files -Manifest files describe a viewer's identity and capabilities. The default manifests are hosted in the [`@bioimagetools/capability-manifest`](https://github.com/BioImageTools/capability-manifest) repository. You can host your own manifest files anywhere accessible via URL. - -### Manifest Structure - -A manifest has two sections: `viewer` (identity) and `capabilities` (what the viewer supports). - -#### Example: `neuroglancer.yaml` - -```yaml -viewer: - name: "Neuroglancer" - version: "2.41.2" - repo: "https://github.com/google/neuroglancer" - template_url: https://neuroglancer-demo.appspot.com/#!{"layers":[{"name":"image","source":"{DATA_URL}","type":"image"}]} - -capabilities: - ome_zarr_versions: [0.4, 0.5] - compression_codecs: ["blosc", "zstd", "zlib", "lz4", "gzip"] - rfcs_supported: [] - axes: true - scale: true - translation: true - channels: true - timepoints: true - labels: false - hcs_plates: false - bioformats2raw_layout: false - omero_metadata: false -``` - -### Viewer Section - -| Field | Description | -| -------------- | -------------------------------------------------------------- | -| `name` | Display name for the viewer | -| `version` | Viewer version | -| `repo` | Repository URL | -| `template_url` | URL template with `{DATA_URL}` placeholder for the dataset URL | - -### Capabilities Section - -| Field | Type | Description | -| ----------------------- | -------- | ------------------------------------------------------------ | -| `ome_zarr_versions` | number[] | Supported OME-Zarr specification versions | -| `compression_codecs` | string[] | Supported compression codecs (e.g., "blosc", "zstd", "gzip") | -| `rfcs_supported` | string[] | Additional RFCs supported | -| `axes` | boolean | Whether axis names and units are respected | -| `scale` | boolean | Whether scaling factors on multiscales are respected | -| `translation` | boolean | Whether translation factors on multiscales are respected | -| `channels` | boolean | Whether multiple channels are supported | -| `timepoints` | boolean | Whether multiple timepoints are supported | -| `labels` | boolean | Whether labels are loaded when available | -| `hcs_plates` | boolean | Whether HCS plates are loaded when available | -| `bioformats2raw_layout` | boolean | Whether bioformats2raw layout is handled | -| `omero_metadata` | boolean | Whether OMERO metadata is used (e.g., channel colors) | - -## URL Templates and `{DATA_URL}` Placeholder - -The `{DATA_URL}` placeholder in a manifest's `template_url` (or a config entry's `instance_template_url`) is replaced at runtime with the actual dataset URL. Internally, `{DATA_URL}` is normalized to `{dataLink}` for consistency with the rest of the application. - -For example, given this manifest `template_url`: - -``` -https://neuroglancer-demo.appspot.com/#!{"layers":[{"name":"image","source":"{DATA_URL}","type":"image"}]} -``` - -When a user clicks the viewer link for a dataset at `https://example.com/data.zarr`, the final URL becomes: - -``` -https://neuroglancer-demo.appspot.com/#!{"layers":[{"name":"image","source":"https://example.com/data.zarr","type":"image"}]} -``` +Manifest files describe a viewer's identity and capabilities. The default manifests are hosted in the [`@bioimagetools/capability-manifest`](https://github.com/BioImageTools/capability-manifest) repository. You can host your own manifest files anywhere accessible via URL. See the [`@bioimagetools/capability-manifest`](https://github.com/BioImageTools/capability-manifest) repository for information on how to format a viewer manifest. ## Configuration Examples @@ -159,29 +91,7 @@ viewers: To add a new viewer, create a capability manifest YAML file, host it at a URL, and reference it in the config: -1. Create a manifest file (e.g., `my-viewer.yaml`): - -```yaml -viewer: - name: "My Viewer" - version: "1.0.0" - repo: "https://github.com/example/my-viewer" - template_url: "https://viewer.example.com/?data={DATA_URL}" - -capabilities: - ome_zarr_versions: [0.4, 0.5] - compression_codecs: ["blosc", "gzip"] - rfcs_supported: [] - axes: true - scale: true - translation: true - channels: true - timepoints: false - labels: false - hcs_plates: false - bioformats2raw_layout: false - omero_metadata: false -``` +1. Create a manifest file (e.g., `my-viewer.yaml`). Follow the format guidelines in the [`@bioimagetools/capability-manifest`](https://github.com/BioImageTools/capability-manifest) repository. 2. Host the manifest at an accessible URL (e.g., on GitHub or any web server). @@ -193,8 +103,6 @@ viewers: label: "Open in My Viewer" ``` -4. Optionally, add a `logo` field to the viewer's capability manifest pointing to a hosted logo image. If no logo is specified, one is derived by convention from the viewer name. If the logo fails to load, a fallback image is shown. - ## How Compatibility Works The `@bioimagetools/capability-manifest` library handles all compatibility checking. When a user views an OME-Zarr dataset: From 2c0d7e1d14f9c864a3bc9168caaa109c438f7f91 Mon Sep 17 00:00:00 2001 From: Allison Truhlar Date: Wed, 15 Apr 2026 11:08:42 -0400 Subject: [PATCH 098/104] chore: remove unrelated changes --- .devcontainer/init-firewall.sh | 3 --- 1 file changed, 3 deletions(-) diff --git a/.devcontainer/init-firewall.sh b/.devcontainer/init-firewall.sh index cace824d2..38762ba34 100755 --- a/.devcontainer/init-firewall.sh +++ b/.devcontainer/init-firewall.sh @@ -84,9 +84,6 @@ ALLOWED_DOMAINS=( "conda-mapping.prefix.dev" "prefix.dev" "repo.prefix.dev" - # Fileglancer - "fileglancer.int.janelia.org" - "s3.janelia.org" ) for domain in "${ALLOWED_DOMAINS[@]}"; do From 7befac87de1acd89a775c39639662d59af7c9221 Mon Sep 17 00:00:00 2001 From: Allison Truhlar Date: Wed, 15 Apr 2026 12:54:27 -0400 Subject: [PATCH 099/104] fix: update package-lock.json --- frontend/package-lock.json | 1645 +++++++++++++++++++----------------- 1 file changed, 891 insertions(+), 754 deletions(-) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 1a83da547..c60f4537c 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -72,23 +72,6 @@ "vitest": "^3.1.3" } }, - "../../capability-manifest": { - "name": "@bioimagetools/capability-manifest", - "version": "0.5.0", - "license": "ISC", - "dependencies": { - "js-yaml": "^4.1.1" - }, - "devDependencies": { - "@types/js-yaml": "^4.0.9", - "@types/node": "^24.10.1", - "ome-zarr.js": "^0.0.17", - "typescript": "^5.9.3", - "vite": "^7.2.2", - "vitest": "^4.0.18", - "zarrita": "^0.5.4" - } - }, "node_modules/@adobe/css-tools": { "version": "4.4.4", "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.4.tgz", @@ -123,12 +106,12 @@ } }, "node_modules/@babel/code-frame": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", - "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", "license": "MIT", "dependencies": { - "@babel/helper-validator-identifier": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" }, @@ -137,29 +120,29 @@ } }, "node_modules/@babel/compat-data": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.5.tgz", - "integrity": "sha512-6uFXyCayocRbqhZOB+6XcuZbkMNimwfVGFji8CTZnCzOHVGvDqzvitu1re2AU5LROliz7eQPhB8CpAMvnx9EjA==", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", + "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/core": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz", - "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.27.1", - "@babel/generator": "^7.28.5", - "@babel/helper-compilation-targets": "^7.27.2", - "@babel/helper-module-transforms": "^7.28.3", - "@babel/helpers": "^7.28.4", - "@babel/parser": "^7.28.5", - "@babel/template": "^7.27.2", - "@babel/traverse": "^7.28.5", - "@babel/types": "^7.28.5", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", @@ -185,13 +168,13 @@ } }, "node_modules/@babel/generator": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.5.tgz", - "integrity": "sha512-3EwLFhZ38J4VyIP6WNtt2kUdW9dokXA9Cr4IVIFHuCpZ3H8/YFOl5JjZHisrn1fATPBmKKqXzDFvh9fUwHz6CQ==", + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", "license": "MIT", "dependencies": { - "@babel/parser": "^7.28.5", - "@babel/types": "^7.28.5", + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" @@ -201,12 +184,12 @@ } }, "node_modules/@babel/helper-compilation-targets": { - "version": "7.27.2", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.2.tgz", - "integrity": "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", "license": "MIT", "dependencies": { - "@babel/compat-data": "^7.27.2", + "@babel/compat-data": "^7.28.6", "@babel/helper-validator-option": "^7.27.1", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", @@ -235,27 +218,27 @@ } }, "node_modules/@babel/helper-module-imports": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz", - "integrity": "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", "license": "MIT", "dependencies": { - "@babel/traverse": "^7.27.1", - "@babel/types": "^7.27.1" + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-module-transforms": { - "version": "7.28.3", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.3.tgz", - "integrity": "sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", "license": "MIT", "dependencies": { - "@babel/helper-module-imports": "^7.27.1", - "@babel/helper-validator-identifier": "^7.27.1", - "@babel/traverse": "^7.28.3" + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" }, "engines": { "node": ">=6.9.0" @@ -265,9 +248,9 @@ } }, "node_modules/@babel/helper-plugin-utils": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.27.1.tgz", - "integrity": "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", + "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", "license": "MIT", "engines": { "node": ">=6.9.0" @@ -301,25 +284,25 @@ } }, "node_modules/@babel/helpers": { - "version": "7.28.4", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.4.tgz", - "integrity": "sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w==", + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz", + "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", "license": "MIT", "dependencies": { - "@babel/template": "^7.27.2", - "@babel/types": "^7.28.4" + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/parser": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.5.tgz", - "integrity": "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==", + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", + "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", "license": "MIT", "dependencies": { - "@babel/types": "^7.28.5" + "@babel/types": "^7.29.0" }, "bin": { "parser": "bin/babel-parser.js" @@ -359,40 +342,40 @@ } }, "node_modules/@babel/runtime": { - "version": "7.28.4", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz", - "integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==", + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", + "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/template": { - "version": "7.27.2", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", - "integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.27.1", - "@babel/parser": "^7.27.2", - "@babel/types": "^7.27.1" + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/traverse": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.5.tgz", - "integrity": "sha512-TCCj4t55U90khlYkVV/0TfkJkAkUg3jZFA3Neb7unZT8CPok7iiRfaX0F+WnqWqt7OxhOn0uBKXCw4lbL8W0aQ==", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.27.1", - "@babel/generator": "^7.28.5", + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", "@babel/helper-globals": "^7.28.0", - "@babel/parser": "^7.28.5", - "@babel/template": "^7.27.2", - "@babel/types": "^7.28.5", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", "debug": "^4.3.1" }, "engines": { @@ -400,9 +383,9 @@ } }, "node_modules/@babel/types": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.5.tgz", - "integrity": "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", "license": "MIT", "dependencies": { "@babel/helper-string-parser": "^7.27.1", @@ -423,44 +406,52 @@ } }, "node_modules/@bioimagetools/capability-manifest": { - "resolved": "../../capability-manifest", - "link": true + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@bioimagetools/capability-manifest/-/capability-manifest-0.5.0.tgz", + "integrity": "sha512-P8H/74W6qcogeYqgFkkEroRnlpuxPZU8HLJLmp+zGbK3AKmjhN/CuHJPOuclAr5i9x2Yurxd7gn2jh2O5Ksr/w==", + "license": "ISC", + "dependencies": { + "js-yaml": "^4.1.1" + } }, "node_modules/@emnapi/core": { - "version": "1.7.1", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.7.1.tgz", - "integrity": "sha512-o1uhUASyo921r2XtHYOHy7gdkGLge8ghBEQHMWmyJFoXlpU58kIrhhN3w26lpQb6dspetweapMn2CSNwQ8I4wg==", + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.2.tgz", + "integrity": "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==", "license": "MIT", "optional": true, + "peer": true, "dependencies": { - "@emnapi/wasi-threads": "1.1.0", + "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" } }, "node_modules/@emnapi/runtime": { - "version": "1.7.1", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.7.1.tgz", - "integrity": "sha512-PVtJr5CmLwYAU9PZDMITZoR5iAOShYREoR45EyyLrbntV50mdePTgUn4AmOw90Ifcj+x2kRjdzr1HP3RrNiHGA==", + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.2.tgz", + "integrity": "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==", "license": "MIT", "optional": true, + "peer": true, "dependencies": { "tslib": "^2.4.0" } }, "node_modules/@emnapi/wasi-threads": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.1.0.tgz", - "integrity": "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", "license": "MIT", "optional": true, + "peer": true, "dependencies": { "tslib": "^2.4.0" } }, "node_modules/@eslint-community/eslint-utils": { - "version": "4.9.0", - "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.0.tgz", - "integrity": "sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g==", + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", "dev": true, "license": "MIT", "dependencies": { @@ -487,24 +478,31 @@ } }, "node_modules/@eslint/config-array": { - "version": "0.21.1", - "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.1.tgz", - "integrity": "sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==", + "version": "0.21.2", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.2.tgz", + "integrity": "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==", "dev": true, "license": "Apache-2.0", "dependencies": { "@eslint/object-schema": "^2.1.7", "debug": "^4.3.1", - "minimatch": "^3.1.2" + "minimatch": "^3.1.5" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@eslint/config-array/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, "node_modules/@eslint/config-array/node_modules/brace-expansion": { - "version": "1.1.13", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", - "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", + "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", "dev": true, "license": "MIT", "dependencies": { @@ -513,9 +511,9 @@ } }, "node_modules/@eslint/config-array/node_modules/minimatch": { - "version": "3.1.4", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.4.tgz", - "integrity": "sha512-twmL+S8+7yIsE9wsqgzU3E8/LumN3M3QELrBZ20OdmQ9jB2JvW5oZtBEmft84k/Gs5CG9mqtWc6Y9vW+JEzGxw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, "license": "ISC", "dependencies": { @@ -580,9 +578,9 @@ } }, "node_modules/@eslint/css-tree": { - "version": "3.6.6", - "resolved": "https://registry.npmjs.org/@eslint/css-tree/-/css-tree-3.6.6.tgz", - "integrity": "sha512-C3YiJMY9OZyZ/3vEMFWJIesdGaRY6DmIYvmtyxMT934CbrOKqRs+Iw7NWSRlJQEaK4dPYy2lZ2y1zkaj8z0p5A==", + "version": "3.6.9", + "resolved": "https://registry.npmjs.org/@eslint/css-tree/-/css-tree-3.6.9.tgz", + "integrity": "sha512-3D5/OHibNEGk+wKwNwMbz63NMf367EoR4mVNNpxddCHKEb2Nez7z62J2U6YjtErSsZDoY0CsccmoUpdEbkogNA==", "dev": true, "license": "MIT", "dependencies": { @@ -594,20 +592,20 @@ } }, "node_modules/@eslint/eslintrc": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.1.tgz", - "integrity": "sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ==", + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.5.tgz", + "integrity": "sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==", "dev": true, "license": "MIT", "dependencies": { - "ajv": "^6.12.4", + "ajv": "^6.14.0", "debug": "^4.3.2", "espree": "^10.0.1", "globals": "^14.0.0", "ignore": "^5.2.0", "import-fresh": "^3.2.1", - "js-yaml": "^4.1.0", - "minimatch": "^3.1.2", + "js-yaml": "^4.1.1", + "minimatch": "^3.1.5", "strip-json-comments": "^3.1.1" }, "engines": { @@ -617,10 +615,17 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/@eslint/eslintrc/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, "node_modules/@eslint/eslintrc/node_modules/brace-expansion": { - "version": "1.1.13", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", - "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", + "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", "dev": true, "license": "MIT", "dependencies": { @@ -642,9 +647,9 @@ } }, "node_modules/@eslint/eslintrc/node_modules/minimatch": { - "version": "3.1.4", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.4.tgz", - "integrity": "sha512-twmL+S8+7yIsE9wsqgzU3E8/LumN3M3QELrBZ20OdmQ9jB2JvW5oZtBEmft84k/Gs5CG9mqtWc6Y9vW+JEzGxw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, "license": "ISC", "dependencies": { @@ -655,9 +660,9 @@ } }, "node_modules/@eslint/js": { - "version": "9.39.1", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.1.tgz", - "integrity": "sha512-S26Stp4zCy88tH94QbBv3XCuzRQiZ9yXofEILmglYTh/Ug/a9/umqvgFtYBAo3Lp0nsI/5/qH1CCrbdK3AP1Tw==", + "version": "9.39.4", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.4.tgz", + "integrity": "sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==", "dev": true, "license": "MIT", "engines": { @@ -784,40 +789,28 @@ } }, "node_modules/@floating-ui/core": { - "version": "1.7.3", - "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.3.tgz", - "integrity": "sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==", + "version": "1.7.5", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz", + "integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==", "license": "MIT", "dependencies": { - "@floating-ui/utils": "^0.2.10" + "@floating-ui/utils": "^0.2.11" } }, - "node_modules/@floating-ui/core/node_modules/@floating-ui/utils": { - "version": "0.2.10", - "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.10.tgz", - "integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==", - "license": "MIT" - }, "node_modules/@floating-ui/dom": { - "version": "1.7.4", - "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.4.tgz", - "integrity": "sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA==", + "version": "1.7.6", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.6.tgz", + "integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==", "license": "MIT", "dependencies": { - "@floating-ui/core": "^1.7.3", - "@floating-ui/utils": "^0.2.10" + "@floating-ui/core": "^1.7.5", + "@floating-ui/utils": "^0.2.11" } }, - "node_modules/@floating-ui/dom/node_modules/@floating-ui/utils": { - "version": "0.2.10", - "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.10.tgz", - "integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==", - "license": "MIT" - }, "node_modules/@floating-ui/utils": { - "version": "0.1.6", - "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.1.6.tgz", - "integrity": "sha512-OfX7E2oUDYxtBvsuS4e/jSn4Q9Qb6DzgeYtsAdkPZ47znpoNsMgZw0+tVijiv3uGNR6dgNlty6r9rzIzHjtd/A==", + "version": "0.2.11", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.11.tgz", + "integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==", "license": "MIT" }, "node_modules/@humanfs/core": { @@ -974,6 +967,7 @@ "version": "8.0.2", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "dev": true, "license": "ISC", "dependencies": { "string-width": "^5.1.2", @@ -991,6 +985,7 @@ "version": "6.2.3", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, "license": "MIT", "engines": { "node": ">=12" @@ -1003,6 +998,7 @@ "version": "8.1.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "dev": true, "license": "MIT", "dependencies": { "ansi-styles": "^6.1.0", @@ -1017,9 +1013,9 @@ } }, "node_modules/@istanbuljs/schema": { - "version": "0.1.3", - "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz", - "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==", + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.6.tgz", + "integrity": "sha512-+Sg6GCR/wy1oSmQDFq4LQDAhm3ETKnorxN+y5nbLULOR3P0c14f2Wurzj3/xqPXtasLFfHd5iRFQ7AJt4KH2cw==", "dev": true, "license": "MIT", "engines": { @@ -1104,22 +1100,28 @@ } }, "node_modules/@material-tailwind/react/node_modules/@floating-ui/react/node_modules/@floating-ui/react-dom": { - "version": "2.1.6", - "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.6.tgz", - "integrity": "sha512-4JX6rEatQEvlmgU80wZyq9RT96HZJa88q8hp0pBd+LrczeDI4o6uA2M+uvxngVHo4Ihr8uibXxH6+70zhAFrVw==", + "version": "2.1.8", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.8.tgz", + "integrity": "sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A==", "license": "MIT", "dependencies": { - "@floating-ui/dom": "^1.7.4" + "@floating-ui/dom": "^1.7.6" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, + "node_modules/@material-tailwind/react/node_modules/@floating-ui/utils": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.1.6.tgz", + "integrity": "sha512-OfX7E2oUDYxtBvsuS4e/jSn4Q9Qb6DzgeYtsAdkPZ47znpoNsMgZw0+tVijiv3uGNR6dgNlty6r9rzIzHjtd/A==", + "license": "MIT" + }, "node_modules/@mswjs/interceptors": { - "version": "0.40.0", - "resolved": "https://registry.npmjs.org/@mswjs/interceptors/-/interceptors-0.40.0.tgz", - "integrity": "sha512-EFd6cVbHsgLa6wa4RljGj6Wk75qoHxUSyc5asLyyPSyuhIcdS2Q3Phw6ImS1q+CkALthJRShiYfKANcQMuMqsQ==", + "version": "0.41.3", + "resolved": "https://registry.npmjs.org/@mswjs/interceptors/-/interceptors-0.41.3.tgz", + "integrity": "sha512-cXu86tF4VQVfwz8W1SPbhoRyHJkti6mjH/XJIxp40jhO4j2k1m4KYrEykxqWPkFF3vrK4rgQppBh//AwyGSXPA==", "dev": true, "license": "MIT", "dependencies": { @@ -1135,15 +1137,21 @@ } }, "node_modules/@napi-rs/wasm-runtime": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.0.7.tgz", - "integrity": "sha512-SeDnOO0Tk7Okiq6DbXmmBODgOAb9dp9gjlphokTUxmt8U3liIP1ZsozBahH69j/RJv+Rfs6IwUKHTgQYJ/HBAw==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", + "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==", "license": "MIT", "optional": true, "dependencies": { - "@emnapi/core": "^1.5.0", - "@emnapi/runtime": "^1.5.0", "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" } }, "node_modules/@nodelib/fs.scandir": { @@ -1207,18 +1215,18 @@ "license": "MIT" }, "node_modules/@oxc-project/runtime": { - "version": "0.97.0", - "resolved": "https://registry.npmjs.org/@oxc-project/runtime/-/runtime-0.97.0.tgz", - "integrity": "sha512-yH0zw7z+jEws4dZ4IUKoix5Lh3yhqIJWF9Dc8PWvhpo7U7O+lJrv7ZZL4BeRO0la8LBQFwcCewtLBnVV7hPe/w==", + "version": "0.101.0", + "resolved": "https://registry.npmjs.org/@oxc-project/runtime/-/runtime-0.101.0.tgz", + "integrity": "sha512-t3qpfVZIqSiLQ5Kqt/MC4Ge/WCOGrrcagAdzTcDaggupjiGxUx4nJF2v6wUCXWSzWHn5Ns7XLv13fCJEwCOERQ==", "license": "MIT", "engines": { "node": "^20.19.0 || >=22.12.0" } }, "node_modules/@oxc-project/types": { - "version": "0.97.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.97.0.tgz", - "integrity": "sha512-lxmZK4xFrdvU0yZiDwgVQTCvh2gHWBJCBk5ALsrtsBWhs0uDIi+FTOnXRQeQfs304imdvTdaakT/lqwQ8hkOXQ==", + "version": "0.101.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.101.0.tgz", + "integrity": "sha512-nuFhqlUzJX+gVIPPfuE6xurd4lST3mdcWOhyK/rZO0B9XWMKm79SuszIQEnSMmmDhq1DC8WWVYGVd+6F93o1gQ==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/Boshen" @@ -1228,6 +1236,7 @@ "version": "0.11.0", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "dev": true, "license": "MIT", "optional": true, "engines": { @@ -1248,9 +1257,9 @@ } }, "node_modules/@rolldown/binding-android-arm64": { - "version": "1.0.0-beta.50", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-beta.50.tgz", - "integrity": "sha512-XlEkrOIHLyGT3avOgzfTFSjG+f+dZMw+/qd+Y3HLN86wlndrB/gSimrJCk4gOhr1XtRtEKfszpadI3Md4Z4/Ag==", + "version": "1.0.0-beta.53", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-beta.53.tgz", + "integrity": "sha512-Ok9V8o7o6YfSdTTYA/uHH30r3YtOxLD6G3wih/U9DO0ucBBFq8WPt/DslU53OgfteLRHITZny9N/qCUxMf9kjQ==", "cpu": [ "arm64" ], @@ -1264,9 +1273,9 @@ } }, "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.0.0-beta.50", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-beta.50.tgz", - "integrity": "sha512-+JRqKJhoFlt5r9q+DecAGPLZ5PxeLva+wCMtAuoFMWPoZzgcYrr599KQ+Ix0jwll4B4HGP43avu9My8KtSOR+w==", + "version": "1.0.0-beta.53", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-beta.53.tgz", + "integrity": "sha512-yIsKqMz0CtRnVa6x3Pa+mzTihr4Ty+Z6HfPbZ7RVbk1Uxnco4+CUn7Qbm/5SBol1JD/7nvY8rphAgyAi7Lj6Vg==", "cpu": [ "arm64" ], @@ -1280,9 +1289,9 @@ } }, "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.0.0-beta.50", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-beta.50.tgz", - "integrity": "sha512-fFXDjXnuX7/gQZQm/1FoivVtRcyAzdjSik7Eo+9iwPQ9EgtA5/nB2+jmbzaKtMGG3q+BnZbdKHCtOacmNrkIDA==", + "version": "1.0.0-beta.53", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-beta.53.tgz", + "integrity": "sha512-GTXe+mxsCGUnJOFMhfGWmefP7Q9TpYUseHvhAhr21nCTgdS8jPsvirb0tJwM3lN0/u/cg7bpFNa16fQrjKrCjQ==", "cpu": [ "x64" ], @@ -1296,9 +1305,9 @@ } }, "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.0.0-beta.50", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-beta.50.tgz", - "integrity": "sha512-F1b6vARy49tjmT/hbloplzgJS7GIvwWZqt+tAHEstCh0JIh9sa8FAMVqEmYxDviqKBaAI8iVvUREm/Kh/PD26Q==", + "version": "1.0.0-beta.53", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-beta.53.tgz", + "integrity": "sha512-9Tmp7bBvKqyDkMcL4e089pH3RsjD3SUungjmqWtyhNOxoQMh0fSmINTyYV8KXtE+JkxYMPWvnEt+/mfpVCkk8w==", "cpu": [ "x64" ], @@ -1312,9 +1321,9 @@ } }, "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.0.0-beta.50", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-beta.50.tgz", - "integrity": "sha512-U6cR76N8T8M6lHj7EZrQ3xunLPxSvYYxA8vJsBKZiFZkT8YV4kjgCO3KwMJL0NOjQCPGKyiXO07U+KmJzdPGRw==", + "version": "1.0.0-beta.53", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-beta.53.tgz", + "integrity": "sha512-a1y5fiB0iovuzdbjUxa7+Zcvgv+mTmlGGC4XydVIsyl48eoxgaYkA3l9079hyTyhECsPq+mbr0gVQsFU11OJAQ==", "cpu": [ "arm" ], @@ -1328,9 +1337,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.0.0-beta.50", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-beta.50.tgz", - "integrity": "sha512-ONgyjofCrrE3bnh5GZb8EINSFyR/hmwTzZ7oVuyUB170lboza1VMCnb8jgE6MsyyRgHYmN8Lb59i3NKGrxrYjw==", + "version": "1.0.0-beta.53", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-beta.53.tgz", + "integrity": "sha512-bpIGX+ov9PhJYV+wHNXl9rzq4F0QvILiURn0y0oepbQx+7stmQsKA0DhPGwmhfvF856wq+gbM8L92SAa/CBcLg==", "cpu": [ "arm64" ], @@ -1344,9 +1353,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.0.0-beta.50", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-beta.50.tgz", - "integrity": "sha512-L0zRdH2oDPkmB+wvuTl+dJbXCsx62SkqcEqdM+79LOcB+PxbAxxjzHU14BuZIQdXcAVDzfpMfaHWzZuwhhBTcw==", + "version": "1.0.0-beta.53", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-beta.53.tgz", + "integrity": "sha512-bGe5EBB8FVjHBR1mOLOPEFg1Lp3//7geqWkU5NIhxe+yH0W8FVrQ6WRYOap4SUTKdklD/dC4qPLREkMMQ855FA==", "cpu": [ "arm64" ], @@ -1360,9 +1369,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.0.0-beta.50", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-beta.50.tgz", - "integrity": "sha512-gyoI8o/TGpQd3OzkJnh1M2kxy1Bisg8qJ5Gci0sXm9yLFzEXIFdtc4EAzepxGvrT2ri99ar5rdsmNG0zP0SbIg==", + "version": "1.0.0-beta.53", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-beta.53.tgz", + "integrity": "sha512-qL+63WKVQs1CMvFedlPt0U9PiEKJOAL/bsHMKUDS6Vp2Q+YAv/QLPu8rcvkfIMvQ0FPU2WL0aX4eWwF6e/GAnA==", "cpu": [ "x64" ], @@ -1376,9 +1385,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.0.0-beta.50", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-beta.50.tgz", - "integrity": "sha512-zti8A7M+xFDpKlghpcCAzyOi+e5nfUl3QhU023ce5NCgUxRG5zGP2GR9LTydQ1rnIPwZUVBWd4o7NjZDaQxaXA==", + "version": "1.0.0-beta.53", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-beta.53.tgz", + "integrity": "sha512-VGl9JIGjoJh3H8Mb+7xnVqODajBmrdOOb9lxWXdcmxyI+zjB2sux69br0hZJDTyLJfvBoYm439zPACYbCjGRmw==", "cpu": [ "x64" ], @@ -1392,9 +1401,9 @@ } }, "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.0.0-beta.50", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-beta.50.tgz", - "integrity": "sha512-eZUssog7qljrrRU9Mi0eqYEPm3Ch0UwB+qlWPMKSUXHNqhm3TvDZarJQdTevGEfu3EHAXJvBIe0YFYr0TPVaMA==", + "version": "1.0.0-beta.53", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-beta.53.tgz", + "integrity": "sha512-B4iIserJXuSnNzA5xBLFUIjTfhNy7d9sq4FUMQY3GhQWGVhS2RWWzzDnkSU6MUt7/aHUrep0CdQfXUJI9D3W7A==", "cpu": [ "arm64" ], @@ -1408,25 +1417,25 @@ } }, "node_modules/@rolldown/binding-wasm32-wasi": { - "version": "1.0.0-beta.50", - "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-beta.50.tgz", - "integrity": "sha512-nmCN0nIdeUnmgeDXiQ+2HU6FT162o+rxnF7WMkBm4M5Ds8qTU7Dzv2Wrf22bo4ftnlrb2hKK6FSwAJSAe2FWLg==", + "version": "1.0.0-beta.53", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-beta.53.tgz", + "integrity": "sha512-BUjAEgpABEJXilGq/BPh7jeU3WAJ5o15c1ZEgHaDWSz3LB881LQZnbNJHmUiM4d1JQWMYYyR1Y490IBHi2FPJg==", "cpu": [ "wasm32" ], "license": "MIT", "optional": true, "dependencies": { - "@napi-rs/wasm-runtime": "^1.0.7" + "@napi-rs/wasm-runtime": "^1.1.0" }, "engines": { "node": ">=14.0.0" } }, "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.0.0-beta.50", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-beta.50.tgz", - "integrity": "sha512-7kcNLi7Ua59JTTLvbe1dYb028QEPaJPJQHqkmSZ5q3tJueUeb6yjRtx8mw4uIqgWZcnQHAR3PrLN4XRJxvgIkA==", + "version": "1.0.0-beta.53", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-beta.53.tgz", + "integrity": "sha512-s27uU7tpCWSjHBnxyVXHt3rMrQdJq5MHNv3BzsewCIroIw3DJFjMH1dzCPPMUFxnh1r52Nf9IJ/eWp6LDoyGcw==", "cpu": [ "arm64" ], @@ -1439,26 +1448,10 @@ "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@rolldown/binding-win32-ia32-msvc": { - "version": "1.0.0-beta.50", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.0.0-beta.50.tgz", - "integrity": "sha512-lL70VTNvSCdSZkDPPVMwWn/M2yQiYvSoXw9hTLgdIWdUfC3g72UaruezusR6ceRuwHCY1Ayu2LtKqXkBO5LIwg==", - "cpu": [ - "ia32" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.0.0-beta.50", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-beta.50.tgz", - "integrity": "sha512-4qU4x5DXWB4JPjyTne/wBNPqkbQU8J45bl21geERBKtEittleonioACBL1R0PsBu0Aq21SwMK5a9zdBkWSlQtQ==", + "version": "1.0.0-beta.53", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-beta.53.tgz", + "integrity": "sha512-cjWL/USPJ1g0en2htb4ssMjIycc36RvdQAx1WlXnS6DpULswiUTVXPDesTifSKYSyvx24E0YqQkEm0K/M2Z/AA==", "cpu": [ "x64" ], @@ -1472,9 +1465,9 @@ } }, "node_modules/@rolldown/pluginutils": { - "version": "1.0.0-beta.47", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.47.tgz", - "integrity": "sha512-8QagwMH3kNCuzD8EWL8R2YPW5e4OrHNSAHRFDdmFqEwEaD/KcNKjVoumo+gP2vW5eKB2UPbM6vTYiGZX0ixLnw==", + "version": "1.0.0-rc.3", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz", + "integrity": "sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q==", "license": "MIT" }, "node_modules/@rollup/plugin-inject": { @@ -1558,26 +1551,32 @@ "license": "Apache-2.0" }, "node_modules/@tanstack/eslint-plugin-query": { - "version": "5.91.2", - "resolved": "https://registry.npmjs.org/@tanstack/eslint-plugin-query/-/eslint-plugin-query-5.91.2.tgz", - "integrity": "sha512-UPeWKl/Acu1IuuHJlsN+eITUHqAaa9/04geHHPedY8siVarSaWprY0SVMKrkpKfk5ehRT7+/MZ5QwWuEtkWrFw==", + "version": "5.99.0", + "resolved": "https://registry.npmjs.org/@tanstack/eslint-plugin-query/-/eslint-plugin-query-5.99.0.tgz", + "integrity": "sha512-jVp1AEL7S7BeuQvH5SN1F5UdrNW/AbryKDeWUUMeAKNzh9C+Ik/bRSa/HeuJLlmaN+WOUkdDFbtCK0go7BxnUQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/utils": "^8.44.1" + "@typescript-eslint/utils": "^8.58.1" }, "funding": { "type": "github", "url": "https://github.com/sponsors/tannerlinsley" }, "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0" + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": "^5.4.0 || ^6.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } } }, "node_modules/@tanstack/hotkeys": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/@tanstack/hotkeys/-/hotkeys-0.4.1.tgz", - "integrity": "sha512-EGHqcdKP2jzy0dEkahA3ABtEXohMqPlU3Ac04sBQjgesJqr9xWuesJotOfWPh3P68kQQg8krNAtFTydIN3+WSw==", + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@tanstack/hotkeys/-/hotkeys-0.4.2.tgz", + "integrity": "sha512-dCCu6Q91wZ2Mz7Vb+tzzpbKH0cSY9JXqJS7ZyouxewNL8oVmI228P9BmP94/1255g5WjPS+njenyrbWVeEQP5Q==", "license": "MIT", "dependencies": { "@tanstack/store": "^0.9.2" @@ -1591,9 +1590,9 @@ } }, "node_modules/@tanstack/query-core": { - "version": "5.90.10", - "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.90.10.tgz", - "integrity": "sha512-EhZVFu9rl7GfRNuJLJ3Y7wtbTnENsvzp+YpcAV7kCYiXni1v8qZh++lpw4ch4rrwC0u/EZRnBHIehzCGzwXDSQ==", + "version": "5.99.0", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.99.0.tgz", + "integrity": "sha512-3Jv3WQG0BCcH7G+7lf/bP8QyBfJOXeY+T08Rin3GZ1bshvwlbPt7NrDHMEzGdKIOmOzvIQmxjk28YEQX60k7pQ==", "license": "MIT", "funding": { "type": "github", @@ -1614,12 +1613,12 @@ } }, "node_modules/@tanstack/react-hotkeys": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/@tanstack/react-hotkeys/-/react-hotkeys-0.4.1.tgz", - "integrity": "sha512-hFh/kKQODn4kSytfIsEE/Vf1AaAb+NAFi4lx+OB49NmKY5z/BNH1/uEdYlVgOEvnDm4QrCISIMBOVpMgK5QNQg==", + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@tanstack/react-hotkeys/-/react-hotkeys-0.4.2.tgz", + "integrity": "sha512-7AMAmX+l1k0tHCaDgT5lHHF0cmtrmK0aaKxR6DliSdAVVjfmyraPZQapLwpyNKoTKNYIROj+2Wg+OvWYP52Oog==", "license": "MIT", "dependencies": { - "@tanstack/hotkeys": "0.4.1", + "@tanstack/hotkeys": "0.4.2", "@tanstack/react-store": "^0.9.2" }, "engines": { @@ -1635,12 +1634,12 @@ } }, "node_modules/@tanstack/react-query": { - "version": "5.90.10", - "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.10.tgz", - "integrity": "sha512-BKLss9Y8PQ9IUjPYQiv3/Zmlx92uxffUOX8ZZNoQlCIZBJPT5M+GOMQj7xislvVQ6l1BstBjcX0XB/aHfFYVNw==", + "version": "5.99.0", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.99.0.tgz", + "integrity": "sha512-OY2bCqPemT1LlqJ8Y2CUau4KELnIhhG9Ol3ZndPbdnB095pRbPo1cHuXTndg8iIwtoHTgwZjyaDnQ0xD0mYwAw==", "license": "MIT", "dependencies": { - "@tanstack/query-core": "5.90.10" + "@tanstack/query-core": "5.99.0" }, "funding": { "type": "github", @@ -1667,12 +1666,12 @@ } }, "node_modules/@tanstack/react-store": { - "version": "0.9.2", - "resolved": "https://registry.npmjs.org/@tanstack/react-store/-/react-store-0.9.2.tgz", - "integrity": "sha512-Vt5usJE5sHG/cMechQfmwvwne6ktGCELe89Lmvoxe3LKRoFrhPa8OCKWs0NliG8HTJElEIj7PLtaBQIcux5pAQ==", + "version": "0.9.3", + "resolved": "https://registry.npmjs.org/@tanstack/react-store/-/react-store-0.9.3.tgz", + "integrity": "sha512-y2iHd/N9OkoQbFJLUX1T9vbc2O9tjH0pQRgTcx1/Nz4IlwLvkgpuglXUx+mXt0g5ZDFrEeDnONPqkbfxXJKwRg==", "license": "MIT", "dependencies": { - "@tanstack/store": "0.9.2", + "@tanstack/store": "0.9.3", "use-sync-external-store": "^1.6.0" }, "funding": { @@ -1722,9 +1721,9 @@ } }, "node_modules/@tanstack/store": { - "version": "0.9.2", - "resolved": "https://registry.npmjs.org/@tanstack/store/-/store-0.9.2.tgz", - "integrity": "sha512-K013lUJEFJK2ofFQ/hZKJUmCnpcV00ebLyOyFOWQvyQHUOZp/iYO84BM6aOGiV81JzwbX0APTVmW8YI7yiG5oA==", + "version": "0.9.3", + "resolved": "https://registry.npmjs.org/@tanstack/store/-/store-0.9.3.tgz", + "integrity": "sha512-8reSzl/qGWGGVKhBoxXPMWzATSbZLZFWhwBAFO9NAyp0TxzfBP0mIrGb8CP8KrQTmvzXlR/vFPPUrHTLBGyFyw==", "license": "MIT", "funding": { "type": "github", @@ -1802,9 +1801,9 @@ "license": "MIT" }, "node_modules/@testing-library/react": { - "version": "16.3.0", - "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.0.tgz", - "integrity": "sha512-kFSyxiEDwv1WLl2fgsq6pPBbw5aWKrsY2/noi1Id0TK0UParSF62oFQFGHXIyaG4pp2tEub/Zlel+fjjZILDsw==", + "version": "16.3.2", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz", + "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==", "dev": true, "license": "MIT", "dependencies": { @@ -1913,9 +1912,9 @@ } }, "node_modules/@types/debug": { - "version": "4.1.12", - "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz", - "integrity": "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==", + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz", + "integrity": "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==", "dev": true, "license": "MIT", "dependencies": { @@ -1976,19 +1975,19 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "20.19.25", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.25.tgz", - "integrity": "sha512-ZsJzA5thDQMSQO788d7IocwwQbI8B5OPzmqNvpf3NY/+MHDAS759Wo0gd2WQeXYt5AAAQjzcrTVC6SKCuYgoCQ==", + "version": "25.6.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.0.tgz", + "integrity": "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==", "devOptional": true, "license": "MIT", "dependencies": { - "undici-types": "~6.21.0" + "undici-types": "~7.19.0" } }, "node_modules/@types/prismjs": { - "version": "1.26.5", - "resolved": "https://registry.npmjs.org/@types/prismjs/-/prismjs-1.26.5.tgz", - "integrity": "sha512-AUZTa7hQ2KY5L7AmtSiqxlhWxb4ina0yd8hNbl4TWuqnv/pFP0nDMb3YrfSBf4hJVGLh2YEIBfKaBW/9UEl6IQ==", + "version": "1.26.6", + "resolved": "https://registry.npmjs.org/@types/prismjs/-/prismjs-1.26.6.tgz", + "integrity": "sha512-vqlvI7qlMvcCBbVe0AKAb4f97//Hy0EBTaiW8AalRnG/xAN5zOiWWyrNqNXeq8+KAuvRewjCVY1+IPxk4RdNYw==", "license": "MIT" }, "node_modules/@types/prop-types": { @@ -1998,9 +1997,9 @@ "license": "MIT" }, "node_modules/@types/react": { - "version": "18.3.27", - "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.27.tgz", - "integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==", + "version": "18.3.28", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz", + "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==", "license": "MIT", "dependencies": { "@types/prop-types": "*", @@ -2057,21 +2056,20 @@ } }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.47.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.47.0.tgz", - "integrity": "sha512-fe0rz9WJQ5t2iaLfdbDc9T80GJy0AeO453q8C3YCilnGozvOyCG5t+EZtg7j7D88+c3FipfP/x+wzGnh1xp8ZA==", + "version": "8.58.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.58.2.tgz", + "integrity": "sha512-aC2qc5thQahutKjP+cl8cgN9DWe3ZUqVko30CMSZHnFEHyhOYoZSzkGtAI2mcwZ38xeImDucI4dnqsHiOYuuCw==", "dev": true, "license": "MIT", "dependencies": { - "@eslint-community/regexpp": "^4.10.0", - "@typescript-eslint/scope-manager": "8.47.0", - "@typescript-eslint/type-utils": "8.47.0", - "@typescript-eslint/utils": "8.47.0", - "@typescript-eslint/visitor-keys": "8.47.0", - "graphemer": "^1.4.0", - "ignore": "^7.0.0", + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.58.2", + "@typescript-eslint/type-utils": "8.58.2", + "@typescript-eslint/utils": "8.58.2", + "@typescript-eslint/visitor-keys": "8.58.2", + "ignore": "^7.0.5", "natural-compare": "^1.4.0", - "ts-api-utils": "^2.1.0" + "ts-api-utils": "^2.5.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -2081,9 +2079,9 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.47.0", - "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <6.0.0" + "@typescript-eslint/parser": "^8.58.2", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { @@ -2097,17 +2095,17 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "8.47.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.47.0.tgz", - "integrity": "sha512-lJi3PfxVmo0AkEY93ecfN+r8SofEqZNGByvHAI3GBLrvt1Cw6H5k1IM02nSzu0RfUafr2EvFSw0wAsZgubNplQ==", + "version": "8.58.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.58.2.tgz", + "integrity": "sha512-/Zb/xaIDfxeJnvishjGdcR4jmr7S+bda8PKNhRGdljDM+elXhlvN0FyPSsMnLmJUrVG9aPO6dof80wjMawsASg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "8.47.0", - "@typescript-eslint/types": "8.47.0", - "@typescript-eslint/typescript-estree": "8.47.0", - "@typescript-eslint/visitor-keys": "8.47.0", - "debug": "^4.3.4" + "@typescript-eslint/scope-manager": "8.58.2", + "@typescript-eslint/types": "8.58.2", + "@typescript-eslint/typescript-estree": "8.58.2", + "@typescript-eslint/visitor-keys": "8.58.2", + "debug": "^4.4.3" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -2117,20 +2115,20 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <6.0.0" + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/project-service": { - "version": "8.47.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.47.0.tgz", - "integrity": "sha512-2X4BX8hUeB5JcA1TQJ7GjcgulXQ+5UkNb0DL8gHsHUHdFoiCTJoYLTpib3LtSDPZsRET5ygN4qqIWrHyYIKERA==", + "version": "8.58.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.58.2.tgz", + "integrity": "sha512-Cq6UfpZZk15+r87BkIh5rDpi38W4b+Sjnb8wQCPPDDweS/LRCFjCyViEbzHk5Ck3f2QDfgmlxqSa7S7clDtlfg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.47.0", - "@typescript-eslint/types": "^8.47.0", - "debug": "^4.3.4" + "@typescript-eslint/tsconfig-utils": "^8.58.2", + "@typescript-eslint/types": "^8.58.2", + "debug": "^4.4.3" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -2140,18 +2138,18 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "typescript": ">=4.8.4 <6.0.0" + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.47.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.47.0.tgz", - "integrity": "sha512-a0TTJk4HXMkfpFkL9/WaGTNuv7JWfFTQFJd6zS9dVAjKsojmv9HT55xzbEpnZoY+VUb+YXLMp+ihMLz/UlZfDg==", + "version": "8.58.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.58.2.tgz", + "integrity": "sha512-SgmyvDPexWETQek+qzZnrG6844IaO02UVyOLhI4wpo82dpZJY9+6YZCKAMFzXb7qhx37mFK1QcPQ18tud+vo6Q==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.47.0", - "@typescript-eslint/visitor-keys": "8.47.0" + "@typescript-eslint/types": "8.58.2", + "@typescript-eslint/visitor-keys": "8.58.2" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -2162,9 +2160,9 @@ } }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.47.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.47.0.tgz", - "integrity": "sha512-ybUAvjy4ZCL11uryalkKxuT3w3sXJAuWhOoGS3T/Wu+iUu1tGJmk5ytSY8gbdACNARmcYEB0COksD2j6hfGK2g==", + "version": "8.58.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.58.2.tgz", + "integrity": "sha512-3SR+RukipDvkkKp/d0jP0dyzuls3DbGmwDpVEc5wqk5f38KFThakqAAO0XMirWAE+kT00oTauTbzMFGPoAzB0A==", "dev": true, "license": "MIT", "engines": { @@ -2175,21 +2173,21 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "typescript": ">=4.8.4 <6.0.0" + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.47.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.47.0.tgz", - "integrity": "sha512-QC9RiCmZ2HmIdCEvhd1aJELBlD93ErziOXXlHEZyuBo3tBiAZieya0HLIxp+DoDWlsQqDawyKuNEhORyku+P8A==", + "version": "8.58.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.58.2.tgz", + "integrity": "sha512-Z7EloNR/B389FvabdGeTo2XMs4W9TjtPiO9DAsmT0yom0bwlPyRjkJ1uCdW1DvrrrYP50AJZ9Xc3sByZA9+dcg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.47.0", - "@typescript-eslint/typescript-estree": "8.47.0", - "@typescript-eslint/utils": "8.47.0", - "debug": "^4.3.4", - "ts-api-utils": "^2.1.0" + "@typescript-eslint/types": "8.58.2", + "@typescript-eslint/typescript-estree": "8.58.2", + "@typescript-eslint/utils": "8.58.2", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -2199,14 +2197,14 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <6.0.0" + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/types": { - "version": "8.47.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.47.0.tgz", - "integrity": "sha512-nHAE6bMKsizhA2uuYZbEbmp5z2UpffNrPEqiKIeN7VsV6UY/roxanWfoRrf6x/k9+Obf+GQdkm0nPU+vnMXo9A==", + "version": "8.58.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.58.2.tgz", + "integrity": "sha512-9TukXyATBQf/Jq9AMQXfvurk+G5R2MwfqQGDR2GzGz28HvY/lXNKGhkY+6IOubwcquikWk5cjlgPvD2uAA7htQ==", "dev": true, "license": "MIT", "engines": { @@ -2218,22 +2216,21 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.47.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.47.0.tgz", - "integrity": "sha512-k6ti9UepJf5NpzCjH31hQNLHQWupTRPhZ+KFF8WtTuTpy7uHPfeg2NM7cP27aCGajoEplxJDFVCEm9TGPYyiVg==", + "version": "8.58.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.58.2.tgz", + "integrity": "sha512-ELGuoofuhhoCvNbQjFFiobFcGgcDCEm0ThWdmO4Z0UzLqPXS3KFvnEZ+SHewwOYHjM09tkzOWXNTv9u6Gqtyuw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.47.0", - "@typescript-eslint/tsconfig-utils": "8.47.0", - "@typescript-eslint/types": "8.47.0", - "@typescript-eslint/visitor-keys": "8.47.0", - "debug": "^4.3.4", - "fast-glob": "^3.3.2", - "is-glob": "^4.0.3", - "minimatch": "^9.0.4", - "semver": "^7.6.0", - "ts-api-utils": "^2.1.0" + "@typescript-eslint/project-service": "8.58.2", + "@typescript-eslint/tsconfig-utils": "8.58.2", + "@typescript-eslint/types": "8.58.2", + "@typescript-eslint/visitor-keys": "8.58.2", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -2243,20 +2240,20 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "typescript": ">=4.8.4 <6.0.0" + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/utils": { - "version": "8.47.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.47.0.tgz", - "integrity": "sha512-g7XrNf25iL4TJOiPqatNuaChyqt49a/onq5YsJ9+hXeugK+41LVg7AxikMfM02PC6jbNtZLCJj6AUcQXJS/jGQ==", + "version": "8.58.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.58.2.tgz", + "integrity": "sha512-QZfjHNEzPY8+l0+fIXMvuQ2sJlplB4zgDZvA+NmvZsZv3EQwOcc1DuIU1VJUTWZ/RKouBMhDyNaBMx4sWvrzRA==", "dev": true, "license": "MIT", "dependencies": { - "@eslint-community/eslint-utils": "^4.7.0", - "@typescript-eslint/scope-manager": "8.47.0", - "@typescript-eslint/types": "8.47.0", - "@typescript-eslint/typescript-estree": "8.47.0" + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.58.2", + "@typescript-eslint/types": "8.58.2", + "@typescript-eslint/typescript-estree": "8.58.2" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -2266,19 +2263,19 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <6.0.0" + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.47.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.47.0.tgz", - "integrity": "sha512-SIV3/6eftCy1bNzCQoPmbWsRLujS8t5iDIZ4spZOBHqrM+yfX2ogg8Tt3PDTAVKw3sSCiUgg30uOAvK2r9zGjQ==", + "version": "8.58.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.58.2.tgz", + "integrity": "sha512-f1WO2Lx8a9t8DARmcWAUPJbu0G20bJlj8L4z72K00TMeJAoyLr/tHhI/pzYBLrR4dXWkcxO1cWYZEOX8DKHTqA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.47.0", - "eslint-visitor-keys": "^4.2.1" + "@typescript-eslint/types": "8.58.2", + "eslint-visitor-keys": "^5.0.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -2289,28 +2286,28 @@ } }, "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", - "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", "dev": true, "license": "Apache-2.0", "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" }, "funding": { "url": "https://opencollective.com/eslint" } }, "node_modules/@vitejs/plugin-react": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.1.1.tgz", - "integrity": "sha512-WQfkSw0QbQ5aJ2CHYw23ZGkqnRwqKHD/KYsMeTkZzPT4Jcf0DcBxBtwMJxnu6E7oxw5+JC6ZAiePgh28uJ1HBA==", + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.2.0.tgz", + "integrity": "sha512-YmKkfhOAi3wsB1PhJq5Scj3GXMn3WvtQ/JC0xoopuHoXSdmtdStOpFrYaT1kie2YgFBcIe64ROzMYRjCrYOdYw==", "license": "MIT", "dependencies": { - "@babel/core": "^7.28.5", + "@babel/core": "^7.29.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", - "@rolldown/pluginutils": "1.0.0-beta.47", + "@rolldown/pluginutils": "1.0.0-rc.3", "@types/babel__core": "^7.20.5", "react-refresh": "^0.18.0" }, @@ -2318,7 +2315,7 @@ "node": "^20.19.0 || >=22.12.0" }, "peerDependencies": { - "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" } }, "node_modules/@vitest/coverage-v8": { @@ -2471,19 +2468,19 @@ } }, "node_modules/@zarrita/storage": { - "version": "0.1.3", - "resolved": "https://registry.npmjs.org/@zarrita/storage/-/storage-0.1.3.tgz", - "integrity": "sha512-ZyCMYN3LuCNtKxro9876r/KyHyXV+ie2Bhk1qYsJR4Jp+sAjoVRRNNSJPsJxk64ZgFFezayO5S2hCu88/1Odwg==", + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/@zarrita/storage/-/storage-0.1.4.tgz", + "integrity": "sha512-qURfJAQcQGRfDQ4J9HaCjGaj3jlJKc66bnRk6G/IeLUsM7WKyG7Bzsuf1EZurSXyc0I4LVcu6HaeQQ4d3kZ16g==", "license": "MIT", "dependencies": { "reference-spec-reader": "^0.2.0", - "unzipit": "^1.4.3" + "unzipit": "1.4.3" } }, "node_modules/acorn": { - "version": "8.15.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", - "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", "bin": { @@ -2504,9 +2501,9 @@ } }, "node_modules/ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", + "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", "dev": true, "license": "MIT", "dependencies": { @@ -2524,6 +2521,7 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -2533,6 +2531,7 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, "license": "MIT", "dependencies": { "color-convert": "^2.0.1" @@ -2736,9 +2735,9 @@ } }, "node_modules/asn1.js/node_modules/bn.js": { - "version": "4.12.2", - "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.2.tgz", - "integrity": "sha512-n4DSx829VRTRByMRGdjQ9iqsN0Bh4OolPsFnaZBLcbi8iXcB+kJ9s7EnRt4wILZNV3kPLHkRVfOc/HvhC3ovDw==", + "version": "4.12.3", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.3.tgz", + "integrity": "sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g==", "dev": true, "license": "MIT" }, @@ -2767,21 +2766,21 @@ } }, "node_modules/ast-v8-to-istanbul": { - "version": "0.3.8", - "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-0.3.8.tgz", - "integrity": "sha512-szgSZqUxI5T8mLKvS7WTjF9is+MVbOeLADU73IseOcrqhxr/VAvy6wfoVE39KnKzA7JRhjF5eUagNlHwvZPlKQ==", + "version": "0.3.12", + "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-0.3.12.tgz", + "integrity": "sha512-BRRC8VRZY2R4Z4lFIL35MwNXmwVqBityvOIwETtsCSwvjl0IdgFsy9NhdaA6j74nUdtJJlIypeRhpDam19Wq3g==", "dev": true, "license": "MIT", "dependencies": { "@jridgewell/trace-mapping": "^0.3.31", "estree-walker": "^3.0.3", - "js-tokens": "^9.0.1" + "js-tokens": "^10.0.0" } }, "node_modules/ast-v8-to-istanbul/node_modules/js-tokens": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", - "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", + "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==", "dev": true, "license": "MIT" }, @@ -2796,9 +2795,9 @@ } }, "node_modules/autoprefixer": { - "version": "10.4.22", - "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.22.tgz", - "integrity": "sha512-ARe0v/t9gO28Bznv6GgqARmVqcWOV3mfgUPn9becPHMiD3o9BwlRgaeccZnwTpZ7Zwqrm+c1sUSsMxIzQzc8Xg==", + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.5.0.tgz", + "integrity": "sha512-FMhOoZV4+qR6aTUALKX2rEqGG+oyATvwBt9IIzVR5rMa2HRWPkxf+P+PAJLD1I/H5/II+HuZcBJYEFBpq39ong==", "funding": [ { "type": "opencollective", @@ -2815,10 +2814,9 @@ ], "license": "MIT", "dependencies": { - "browserslist": "^4.27.0", - "caniuse-lite": "^1.0.30001754", + "browserslist": "^4.28.2", + "caniuse-lite": "^1.0.30001787", "fraction.js": "^5.3.4", - "normalize-range": "^0.1.2", "picocolors": "^1.1.1", "postcss-value-parser": "^4.2.0" }, @@ -2849,11 +2847,14 @@ } }, "node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", "dev": true, - "license": "MIT" + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } }, "node_modules/base64-js": { "version": "1.5.1", @@ -2877,12 +2878,15 @@ "license": "MIT" }, "node_modules/baseline-browser-mapping": { - "version": "2.8.29", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.29.tgz", - "integrity": "sha512-sXdt2elaVnhpDNRDz+1BDx1JQoJRuNk7oVlAlbGiFkLikHCAQiccexF/9e91zVi6RCgqspl04aP+6Cnl9zRLrA==", + "version": "2.10.19", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.19.tgz", + "integrity": "sha512-qCkNLi2sfBOn8XhZQ0FXsT1Ki/Yo5P90hrkRamVFRS7/KV9hpfA4HkoWNU152+8w0zPjnxo5psx5NL3PSGgv5g==", "license": "Apache-2.0", "bin": { - "baseline-browser-mapping": "dist/cli.js" + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" } }, "node_modules/binary-extensions": { @@ -2898,9 +2902,9 @@ } }, "node_modules/bn.js": { - "version": "5.2.2", - "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-5.2.2.tgz", - "integrity": "sha512-v2YAxEmKaBLahNwE1mjp4WON6huMNeuDvagFZW+ASCuA/ku0bXR9hSMw0XpiqMoA3+rmnyck/tPRSFQkoC9Cuw==", + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-5.2.3.tgz", + "integrity": "sha512-EAcmnPkxpntVL+DS7bO1zhcZNvCkxqtkd0ZY53h06GNQ3DEkkGZ/gKgmDv6DdZQGj9BgfSPKtJJ7Dp1GPP8f7w==", "dev": true, "license": "MIT" }, @@ -2908,6 +2912,7 @@ "version": "5.0.5", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", + "dev": true, "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" @@ -2916,15 +2921,6 @@ "node": "18 || 20 || >=22" } }, - "node_modules/brace-expansion/node_modules/balanced-match": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", - "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", - "license": "MIT", - "engines": { - "node": "18 || 20 || >=22" - } - }, "node_modules/braces": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", @@ -2955,12 +2951,13 @@ } }, "node_modules/browser-resolve/node_modules/resolve": { - "version": "1.22.11", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", - "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", + "version": "1.22.12", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", + "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", "dev": true, "license": "MIT", "dependencies": { + "es-errors": "^1.3.0", "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" @@ -3109,9 +3106,9 @@ } }, "node_modules/browserslist": { - "version": "4.28.0", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.0.tgz", - "integrity": "sha512-tbydkR/CxfMwelN0vwdP/pLkDwyAASZ+VfWm4EOwlB6SWhx1sYnWLqo8N5j0rAzPfzfRaxt0mM/4wPU/Su84RQ==", + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", "funding": [ { "type": "opencollective", @@ -3128,11 +3125,11 @@ ], "license": "MIT", "dependencies": { - "baseline-browser-mapping": "^2.8.25", - "caniuse-lite": "^1.0.30001754", - "electron-to-chromium": "^1.5.249", - "node-releases": "^2.0.27", - "update-browserslist-db": "^1.1.4" + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" }, "bin": { "browserslist": "cli.js" @@ -3191,15 +3188,15 @@ } }, "node_modules/call-bind": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", - "integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==", + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz", + "integrity": "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==", "dev": true, "license": "MIT", "dependencies": { - "call-bind-apply-helpers": "^1.0.0", - "es-define-property": "^1.0.0", - "get-intrinsic": "^1.2.4", + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "get-intrinsic": "^1.3.0", "set-function-length": "^1.2.2" }, "engines": { @@ -3260,9 +3257,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001755", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001755.tgz", - "integrity": "sha512-44V+Jm6ctPj7R52Na4TLi3Zri4dWUljJd+RDm+j8LtNCc/ihLCT+X1TzoOAkRETEWqjuLnh9581Tl80FvK7jVA==", + "version": "1.0.30001788", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001788.tgz", + "integrity": "sha512-6q8HFp+lOQtcf7wBK+uEenxymVWkGKkjFpCvw5W25cmMwEDU45p1xQFBQv8JDlMMry7eNxyBaR+qxgmTUZkIRQ==", "funding": [ { "type": "opencollective", @@ -3355,9 +3352,9 @@ } }, "node_modules/check-error": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.1.tgz", - "integrity": "sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw==", + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", "dev": true, "license": "MIT", "engines": { @@ -3497,6 +3494,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, "license": "MIT", "dependencies": { "color-name": "~1.1.4" @@ -3509,6 +3507,7 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, "license": "MIT" }, "node_modules/comma-separated-tokens": { @@ -3557,12 +3556,16 @@ "license": "MIT" }, "node_modules/cookie": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.0.2.tgz", - "integrity": "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", "license": "MIT", "engines": { "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/core-util-is": { @@ -3584,9 +3587,9 @@ } }, "node_modules/create-ecdh/node_modules/bn.js": { - "version": "4.12.2", - "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.2.tgz", - "integrity": "sha512-n4DSx829VRTRByMRGdjQ9iqsN0Bh4OolPsFnaZBLcbi8iXcB+kJ9s7EnRt4wILZNV3kPLHkRVfOc/HvhC3ovDw==", + "version": "4.12.3", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.3.tgz", + "integrity": "sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g==", "dev": true, "license": "MIT" }, @@ -3764,9 +3767,9 @@ } }, "node_modules/decode-named-character-reference": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.2.0.tgz", - "integrity": "sha512-c6fcElNV6ShtZXmsgNgFFV5tVX2PaV4g+MOAkb8eXHvn6sryJBrZa9r0zV6+dtTyoCKxtDy5tyQ5ZwQuidtd+Q==", + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz", + "integrity": "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==", "license": "MIT", "dependencies": { "character-entities": "^2.0.0" @@ -3910,9 +3913,9 @@ } }, "node_modules/diffie-hellman/node_modules/bn.js": { - "version": "4.12.2", - "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.2.tgz", - "integrity": "sha512-n4DSx829VRTRByMRGdjQ9iqsN0Bh4OolPsFnaZBLcbi8iXcB+kJ9s7EnRt4wILZNV3kPLHkRVfOc/HvhC3ovDw==", + "version": "4.12.3", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.3.tgz", + "integrity": "sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g==", "dev": true, "license": "MIT" }, @@ -3974,12 +3977,13 @@ "version": "0.2.0", "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "dev": true, "license": "MIT" }, "node_modules/electron-to-chromium": { - "version": "1.5.255", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.255.tgz", - "integrity": "sha512-Z9oIp4HrFF/cZkDPMpz2XSuVpc1THDpT4dlmATFlJUIBVCy9Vap5/rIXsASP1CscBacBqhabwh8vLctqBwEerQ==", + "version": "1.5.336", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.336.tgz", + "integrity": "sha512-AbH9q9J455r/nLmdNZes0G0ZKcRX73FicwowalLs6ijwOmCJSRRrLX63lcAlzy9ux3dWK1w1+1nsBJEWN11hcQ==", "license": "ISC" }, "node_modules/elliptic": { @@ -3999,9 +4003,9 @@ } }, "node_modules/elliptic/node_modules/bn.js": { - "version": "4.12.2", - "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.2.tgz", - "integrity": "sha512-n4DSx829VRTRByMRGdjQ9iqsN0Bh4OolPsFnaZBLcbi8iXcB+kJ9s7EnRt4wILZNV3kPLHkRVfOc/HvhC3ovDw==", + "version": "4.12.3", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.3.tgz", + "integrity": "sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g==", "dev": true, "license": "MIT" }, @@ -4009,6 +4013,7 @@ "version": "9.2.2", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true, "license": "MIT" }, "node_modules/entities": { @@ -4025,9 +4030,9 @@ } }, "node_modules/es-abstract": { - "version": "1.24.0", - "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.0.tgz", - "integrity": "sha512-WSzPgsdLtTcQwm4CROfS5ju2Wa1QQcVeT37jFjYzdFz1r9ahadC8B8/a4qxJxM+09F18iumCdRmlr96ZYkQvEg==", + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.2.tgz", + "integrity": "sha512-2FpH9Q5i2RRwyEP1AylXe6nYLR5OhaJTZwmlcP0dL/+JCbgg7yyEo/sEK6HeGZRf3dFpWwThaRHVApXSkW3xeg==", "dev": true, "license": "MIT", "dependencies": { @@ -4107,35 +4112,34 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" } }, "node_modules/es-iterator-helpers": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/es-iterator-helpers/-/es-iterator-helpers-1.2.1.tgz", - "integrity": "sha512-uDn+FE1yrDzyC0pCo961B2IHbdM8y/ACZsKD4dG6WqrjV53BADjwa7D+1aom2rsNVfLyDgU/eigvlJGJ08OQ4w==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/es-iterator-helpers/-/es-iterator-helpers-1.3.2.tgz", + "integrity": "sha512-HVLACW1TppGYjJ8H6/jqH/pqOtKRw6wMlrB23xfExmFWxFquAIWCmwoLsOyN96K4a5KbmOf5At9ZUO3GZbetAw==", "dev": true, "license": "MIT", "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.3", + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", "define-properties": "^1.2.1", - "es-abstract": "^1.23.6", + "es-abstract": "^1.24.2", "es-errors": "^1.3.0", - "es-set-tostringtag": "^2.0.3", + "es-set-tostringtag": "^2.1.0", "function-bind": "^1.1.2", - "get-intrinsic": "^1.2.6", + "get-intrinsic": "^1.3.0", "globalthis": "^1.0.4", "gopd": "^1.2.0", "has-property-descriptors": "^1.0.2", "has-proto": "^1.2.0", "has-symbols": "^1.1.0", "internal-slot": "^1.1.0", - "iterator.prototype": "^1.1.4", - "safe-array-concat": "^1.1.3" + "iterator.prototype": "^1.1.5", + "math-intrinsics": "^1.1.0" }, "engines": { "node": ">= 0.4" @@ -4231,25 +4235,25 @@ } }, "node_modules/eslint": { - "version": "9.39.1", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.1.tgz", - "integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==", + "version": "9.39.4", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.4.tgz", + "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", - "@eslint/config-array": "^0.21.1", + "@eslint/config-array": "^0.21.2", "@eslint/config-helpers": "^0.4.2", "@eslint/core": "^0.17.0", - "@eslint/eslintrc": "^3.3.1", - "@eslint/js": "9.39.1", + "@eslint/eslintrc": "^3.3.5", + "@eslint/js": "9.39.4", "@eslint/plugin-kit": "^0.4.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", - "ajv": "^6.12.4", + "ajv": "^6.14.0", "chalk": "^4.0.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", @@ -4268,7 +4272,7 @@ "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "lodash.merge": "^4.6.2", - "minimatch": "^3.1.2", + "minimatch": "^3.1.5", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, @@ -4307,14 +4311,14 @@ } }, "node_modules/eslint-plugin-prettier": { - "version": "5.5.4", - "resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.5.4.tgz", - "integrity": "sha512-swNtI95SToIz05YINMA6Ox5R057IMAmWZ26GqPxusAp1TZzj+IdY9tXNWWD3vkF/wEqydCONcwjTFpxybBqZsg==", + "version": "5.5.5", + "resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.5.5.tgz", + "integrity": "sha512-hscXkbqUZ2sPithAuLm5MXL+Wph+U7wHngPBv9OMWwlP8iaflyxpjTYZkmdgB4/vPIhemRlBEoLrH7UC1n7aUw==", "dev": true, "license": "MIT", "dependencies": { - "prettier-linter-helpers": "^1.0.0", - "synckit": "^0.11.7" + "prettier-linter-helpers": "^1.0.1", + "synckit": "^0.11.12" }, "engines": { "node": "^14.18.0 || >=16.0.0" @@ -4384,19 +4388,26 @@ } }, "node_modules/eslint-plugin-react-refresh": { - "version": "0.4.24", - "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.4.24.tgz", - "integrity": "sha512-nLHIW7TEq3aLrEYWpVaJ1dRgFR+wLDPN8e8FpYAql/bMV2oBEfC37K0gLEGgv9fy66juNShSMV8OkTqzltcG/w==", + "version": "0.4.26", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.4.26.tgz", + "integrity": "sha512-1RETEylht2O6FM/MvgnyvT+8K21wLqDNg4qD51Zj3guhjt433XbnnkVttHMyaVyAFD03QSV4LPS5iE3VQmO7XQ==", "dev": true, "license": "MIT", "peerDependencies": { "eslint": ">=8.40" } }, + "node_modules/eslint-plugin-react/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, "node_modules/eslint-plugin-react/node_modules/brace-expansion": { - "version": "1.1.13", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", - "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", + "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", "dev": true, "license": "MIT", "dependencies": { @@ -4405,9 +4416,9 @@ } }, "node_modules/eslint-plugin-react/node_modules/minimatch": { - "version": "3.1.4", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.4.tgz", - "integrity": "sha512-twmL+S8+7yIsE9wsqgzU3E8/LumN3M3QELrBZ20OdmQ9jB2JvW5oZtBEmft84k/Gs5CG9mqtWc6Y9vW+JEzGxw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, "license": "ISC", "dependencies": { @@ -4484,10 +4495,17 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/eslint/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, "node_modules/eslint/node_modules/brace-expansion": { - "version": "1.1.13", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", - "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", + "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", "dev": true, "license": "MIT", "dependencies": { @@ -4509,9 +4527,9 @@ } }, "node_modules/eslint/node_modules/minimatch": { - "version": "3.1.4", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.4.tgz", - "integrity": "sha512-twmL+S8+7yIsE9wsqgzU3E8/LumN3M3QELrBZ20OdmQ9jB2JvW5oZtBEmft84k/Gs5CG9mqtWc6Y9vW+JEzGxw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, "license": "ISC", "dependencies": { @@ -4553,9 +4571,9 @@ } }, "node_modules/esquery": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz", - "integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==", + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", "dev": true, "license": "BSD-3-Clause", "dependencies": { @@ -4630,9 +4648,9 @@ } }, "node_modules/expect-type": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.2.2.tgz", - "integrity": "sha512-JhFGDVJ7tmDJItKhYgJCGLOWjuK9vPxiXoUFLwLDc99NlmklilbiQJwoctZtt13+xMw91MCk/REan6MWHqDjyA==", + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", "dev": true, "license": "Apache-2.0", "engines": { @@ -4696,9 +4714,9 @@ "license": "MIT" }, "node_modules/fastq": { - "version": "1.19.1", - "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz", - "integrity": "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==", + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", "license": "ISC", "dependencies": { "reusify": "^1.0.4" @@ -4807,6 +4825,7 @@ "version": "3.3.1", "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "dev": true, "license": "ISC", "dependencies": { "cross-spawn": "^7.0.6", @@ -4997,6 +5016,8 @@ "version": "10.5.0", "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, "license": "ISC", "dependencies": { "foreground-child": "^3.1.0", @@ -5025,6 +5046,39 @@ "node": ">=10.13.0" } }, + "node_modules/glob/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/glob/node_modules/brace-expansion": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz", + "integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/glob/node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/globals": { "version": "15.15.0", "resolved": "https://registry.npmjs.org/globals/-/globals-15.15.0.tgz", @@ -5077,17 +5131,10 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/graphemer": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", - "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", - "dev": true, - "license": "MIT" - }, "node_modules/graphql": { - "version": "16.12.0", - "resolved": "https://registry.npmjs.org/graphql/-/graphql-16.12.0.tgz", - "integrity": "sha512-DKKrynuQRne0PNpEbzuEdHlYOMksHSUI8Zc9Unei5gTsMNA2/vMpoMz/yKba50pejK56qj98qM0SjYxAKi13gQ==", + "version": "16.13.2", + "resolved": "https://registry.npmjs.org/graphql/-/graphql-16.13.2.tgz", + "integrity": "sha512-5bJ+nf/UCpAjHM8i06fl7eLyVC9iuNAjm9qzkiu2ZGhM0VscSvS6WDPfAwkdkBuoXGM9FJSbKl6wylMwP9Ktig==", "dev": true, "license": "MIT", "engines": { @@ -5095,9 +5142,9 @@ } }, "node_modules/happy-dom": { - "version": "20.8.9", - "resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-20.8.9.tgz", - "integrity": "sha512-Tz23LR9T9jOGVZm2x1EPdXqwA37G/owYMxRwU0E4miurAtFsPMQ1d2Jc2okUaSjZqAFz2oEn3FLXC5a0a+siyA==", + "version": "20.9.0", + "resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-20.9.0.tgz", + "integrity": "sha512-GZZ9mKe8r646NUAf/zemnGbjYh4Bt8/MqASJY+pSm5ZDtc3YQox+4gsLI7yi1hba6o+eCsGxpHn5+iEVn31/FQ==", "dev": true, "license": "MIT", "dependencies": { @@ -5636,6 +5683,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -6003,6 +6051,7 @@ "version": "3.4.3", "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "dev": true, "license": "BlueOak-1.0.0", "dependencies": { "@isaacs/cliui": "^8.0.2" @@ -6299,9 +6348,9 @@ } }, "node_modules/lightningcss": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.2.tgz", - "integrity": "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", "license": "MPL-2.0", "dependencies": { "detect-libc": "^2.0.3" @@ -6314,23 +6363,23 @@ "url": "https://opencollective.com/parcel" }, "optionalDependencies": { - "lightningcss-android-arm64": "1.30.2", - "lightningcss-darwin-arm64": "1.30.2", - "lightningcss-darwin-x64": "1.30.2", - "lightningcss-freebsd-x64": "1.30.2", - "lightningcss-linux-arm-gnueabihf": "1.30.2", - "lightningcss-linux-arm64-gnu": "1.30.2", - "lightningcss-linux-arm64-musl": "1.30.2", - "lightningcss-linux-x64-gnu": "1.30.2", - "lightningcss-linux-x64-musl": "1.30.2", - "lightningcss-win32-arm64-msvc": "1.30.2", - "lightningcss-win32-x64-msvc": "1.30.2" + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" } }, "node_modules/lightningcss-android-arm64": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.30.2.tgz", - "integrity": "sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", "cpu": [ "arm64" ], @@ -6348,9 +6397,9 @@ } }, "node_modules/lightningcss-darwin-arm64": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.30.2.tgz", - "integrity": "sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", "cpu": [ "arm64" ], @@ -6368,9 +6417,9 @@ } }, "node_modules/lightningcss-darwin-x64": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.30.2.tgz", - "integrity": "sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", "cpu": [ "x64" ], @@ -6388,9 +6437,9 @@ } }, "node_modules/lightningcss-freebsd-x64": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.30.2.tgz", - "integrity": "sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", "cpu": [ "x64" ], @@ -6408,9 +6457,9 @@ } }, "node_modules/lightningcss-linux-arm-gnueabihf": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.30.2.tgz", - "integrity": "sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", "cpu": [ "arm" ], @@ -6428,9 +6477,9 @@ } }, "node_modules/lightningcss-linux-arm64-gnu": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.30.2.tgz", - "integrity": "sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", "cpu": [ "arm64" ], @@ -6448,9 +6497,9 @@ } }, "node_modules/lightningcss-linux-arm64-musl": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.30.2.tgz", - "integrity": "sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", "cpu": [ "arm64" ], @@ -6468,9 +6517,9 @@ } }, "node_modules/lightningcss-linux-x64-gnu": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.30.2.tgz", - "integrity": "sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", "cpu": [ "x64" ], @@ -6488,9 +6537,9 @@ } }, "node_modules/lightningcss-linux-x64-musl": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.30.2.tgz", - "integrity": "sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", "cpu": [ "x64" ], @@ -6508,9 +6557,9 @@ } }, "node_modules/lightningcss-win32-arm64-msvc": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.30.2.tgz", - "integrity": "sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", "cpu": [ "arm64" ], @@ -6528,9 +6577,9 @@ } }, "node_modules/lightningcss-win32-x64-msvc": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.30.2.tgz", - "integrity": "sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", "cpu": [ "x64" ], @@ -6785,9 +6834,9 @@ } }, "node_modules/mdast-util-from-markdown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.2.tgz", - "integrity": "sha512-uZhTV/8NBuw0WHkPTrCqDOl0zVe1BIng5ZtHoDk49ME1qqcjYmmLmOf0gELgcRMxN4w2iuIeVso5/6QymSrgmA==", + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.3.tgz", + "integrity": "sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q==", "dev": true, "license": "MIT", "dependencies": { @@ -7659,9 +7708,9 @@ } }, "node_modules/miller-rabin/node_modules/bn.js": { - "version": "4.12.2", - "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.2.tgz", - "integrity": "sha512-n4DSx829VRTRByMRGdjQ9iqsN0Bh4OolPsFnaZBLcbi8iXcB+kJ9s7EnRt4wILZNV3kPLHkRVfOc/HvhC3ovDw==", + "version": "4.12.3", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.3.tgz", + "integrity": "sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g==", "dev": true, "license": "MIT" }, @@ -7690,25 +7739,27 @@ "license": "MIT" }, "node_modules/minimatch": { - "version": "9.0.8", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.8.tgz", - "integrity": "sha512-reYkDYtj/b19TeqbNZCV4q9t+Yxylf/rYBsLb42SXJatTv4/ylq5lEiAmhA/IToxO7NI2UzNMghHoHuaqDkAjw==", - "license": "ISC", + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", "dependencies": { - "brace-expansion": "^5.0.2" + "brace-expansion": "^5.0.5" }, "engines": { - "node": ">=16 || 14 >=14.17" + "node": "18 || 20 || >=22" }, "funding": { "url": "https://github.com/sponsors/isaacs" } }, "node_modules/minipass": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", - "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", - "license": "ISC", + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "dev": true, + "license": "BlueOak-1.0.0", "engines": { "node": ">=16 || 14 >=14.17" } @@ -7720,29 +7771,29 @@ "license": "MIT" }, "node_modules/msw": { - "version": "2.12.2", - "resolved": "https://registry.npmjs.org/msw/-/msw-2.12.2.tgz", - "integrity": "sha512-Fsr8AR5Yu6C0thoWa1Z8qGBFQLDvLsWlAn/v3CNLiUizoRqBYArK3Ex3thXpMWRr1Li5/MKLOEZ5mLygUmWi1A==", + "version": "2.13.3", + "resolved": "https://registry.npmjs.org/msw/-/msw-2.13.3.tgz", + "integrity": "sha512-/F49bxavkNGfreMlrKmTxZs6YorjfMbbDLd89Q3pWi+cXGtQQNXXaHt4MkXN7li91xnQJ24HWXqW9QDm5id33w==", "dev": true, "hasInstallScript": true, "license": "MIT", "dependencies": { "@inquirer/confirm": "^5.0.0", - "@mswjs/interceptors": "^0.40.0", + "@mswjs/interceptors": "^0.41.2", "@open-draft/deferred-promise": "^2.2.0", - "@types/statuses": "^2.0.4", + "@types/statuses": "^2.0.6", "cookie": "^1.0.2", - "graphql": "^16.8.1", + "graphql": "^16.12.0", "headers-polyfill": "^4.0.2", "is-node-process": "^1.2.0", "outvariant": "^1.4.3", "path-to-regexp": "^6.3.0", "picocolors": "^1.1.1", - "rettime": "^0.7.0", + "rettime": "^0.11.7", "statuses": "^2.0.2", "strict-event-emitter": "^0.5.1", "tough-cookie": "^6.0.0", - "type-fest": "^4.26.1", + "type-fest": "^5.2.0", "until-async": "^3.0.2", "yargs": "^17.7.2" }, @@ -7810,10 +7861,39 @@ "dev": true, "license": "MIT" }, + "node_modules/node-exports-info": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/node-exports-info/-/node-exports-info-1.6.0.tgz", + "integrity": "sha512-pyFS63ptit/P5WqUkt+UUfe+4oevH+bFeIiPPdfb0pFeYEu/1ELnJu5l+5EcTKYL5M7zaAa7S8ddywgXypqKCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "array.prototype.flatmap": "^1.3.3", + "es-errors": "^1.3.0", + "object.entries": "^1.1.9", + "semver": "^6.3.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/node-exports-info/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, "node_modules/node-releases": { - "version": "2.0.27", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", - "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", + "version": "2.0.37", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.37.tgz", + "integrity": "sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg==", "license": "MIT" }, "node_modules/node-stdlib-browser": { @@ -7871,15 +7951,6 @@ "node": ">=0.10.0" } }, - "node_modules/normalize-range": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/normalize-range/-/normalize-range-0.1.2.tgz", - "integrity": "sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/npm-normalize-package-bin": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/npm-normalize-package-bin/-/npm-normalize-package-bin-4.0.0.tgz", @@ -7927,13 +7998,43 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/npm-run-all2/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "license": "MIT" + }, + "node_modules/npm-run-all2/node_modules/brace-expansion": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz", + "integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, "node_modules/npm-run-all2/node_modules/isexe": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.1.tgz", - "integrity": "sha512-LpB/54B+/2J5hqQ7imZHfdU31OlgQqx7ZicVlkm9kzg9/w8GKLEcFfJl/t7DCEDueOyBAD6zCCwTO6Fzs0NoEQ==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.5.tgz", + "integrity": "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/npm-run-all2/node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, "engines": { - "node": ">=16" + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, "node_modules/npm-run-all2/node_modules/which": { @@ -8188,6 +8289,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "dev": true, "license": "BlueOak-1.0.0" }, "node_modules/pako": { @@ -8288,6 +8390,7 @@ "version": "1.11.1", "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dev": true, "license": "BlueOak-1.0.0", "dependencies": { "lru-cache": "^10.2.0", @@ -8304,6 +8407,7 @@ "version": "10.4.3", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, "license": "ISC" }, "node_modules/path-to-regexp": { @@ -8420,9 +8524,9 @@ } }, "node_modules/postcss": { - "version": "8.5.6", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", - "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "version": "8.5.10", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz", + "integrity": "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==", "funding": [ { "type": "opencollective", @@ -8465,11 +8569,12 @@ } }, "node_modules/postcss-import/node_modules/resolve": { - "version": "1.22.11", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", - "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", + "version": "1.22.12", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", + "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", "license": "MIT", "dependencies": { + "es-errors": "^1.3.0", "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" @@ -8606,9 +8711,9 @@ } }, "node_modules/prettier": { - "version": "3.6.2", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.6.2.tgz", - "integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==", + "version": "3.8.3", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.3.tgz", + "integrity": "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==", "dev": true, "license": "MIT", "bin": { @@ -8622,9 +8727,9 @@ } }, "node_modules/prettier-linter-helpers": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/prettier-linter-helpers/-/prettier-linter-helpers-1.0.0.tgz", - "integrity": "sha512-GbK2cP9nraSSUF9N2XwUwqfzlAFlMNYYl+ShE/V+H8a9uNl/oUqB1w2EL54Jh0OlyRSd8RfWYJ3coVS4TROP2w==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/prettier-linter-helpers/-/prettier-linter-helpers-1.0.1.tgz", + "integrity": "sha512-SxToR7P8Y2lWmv/kTzVLC1t/GDI2WGjMwNhLLE9qtH8Q13C+aEmuRlzDst4Up4s0Wc8sF2M+J57iB3cMLqftfg==", "dev": true, "license": "MIT", "dependencies": { @@ -8733,9 +8838,9 @@ } }, "node_modules/public-encrypt/node_modules/bn.js": { - "version": "4.12.2", - "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.2.tgz", - "integrity": "sha512-n4DSx829VRTRByMRGdjQ9iqsN0Bh4OolPsFnaZBLcbi8iXcB+kJ9s7EnRt4wILZNV3kPLHkRVfOc/HvhC3ovDw==", + "version": "4.12.3", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.3.tgz", + "integrity": "sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g==", "dev": true, "license": "MIT" }, @@ -8750,9 +8855,9 @@ } }, "node_modules/qs": { - "version": "6.14.2", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz", - "integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==", + "version": "6.15.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.1.tgz", + "integrity": "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==", "dev": true, "license": "BSD-3-Clause", "dependencies": { @@ -8841,15 +8946,12 @@ } }, "node_modules/react-error-boundary": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/react-error-boundary/-/react-error-boundary-6.0.0.tgz", - "integrity": "sha512-gdlJjD7NWr0IfkPlaREN2d9uUZUlksrfOx7SX62VRerwXbMY6ftGCIZua1VG1aXFNOimhISsTq+Owp725b9SiA==", + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/react-error-boundary/-/react-error-boundary-6.1.1.tgz", + "integrity": "sha512-BrYwPOdXi5mqkk5lw+Uvt0ThHx32rCt3BkukS4X23A2AIWDPSGX6iaWTc0y9TU/mHDA/6qOSGel+B2ERkOvD1w==", "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.12.5" - }, "peerDependencies": { - "react": ">=16.13.1" + "react": "^18.0.0 || ^19.0.0" } }, "node_modules/react-hot-toast": { @@ -8870,9 +8972,9 @@ } }, "node_modules/react-icons": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.5.0.tgz", - "integrity": "sha512-MEFcXdkP3dLo8uumGI5xN3lDFNsRtrjbOEKDLD7yv76v4wpnEq2Lt2qeHaQOr34I/wPN3s3+N08WkQ+CW37Xiw==", + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.6.0.tgz", + "integrity": "sha512-RH93p5ki6LfOiIt0UtDyNg/cee+HLVR6cHHtW3wALfo+eOHTp8RnU2kRkI6E+H19zMIs03DyxUG/GfZMOGvmiA==", "license": "MIT", "peerDependencies": { "react": "*" @@ -8905,9 +9007,9 @@ } }, "node_modules/react-router": { - "version": "7.12.0", - "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.12.0.tgz", - "integrity": "sha512-kTPDYPFzDVGIIGNLS5VJykK0HfHLY5MF3b+xj0/tTyNYL1gF1qs7u67Z9jEhQk2sQ98SUaHxlG31g1JtF7IfVw==", + "version": "7.14.1", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.14.1.tgz", + "integrity": "sha512-5BCvFskyAAVumqhEKh/iPhLOIkfxcEUz8WqFIARCkMg8hZZzDYX9CtwxXA0e+qT8zAxmMC0x3Ckb9iMONwc5jg==", "license": "MIT", "dependencies": { "cookie": "^1.0.1", @@ -8927,12 +9029,12 @@ } }, "node_modules/react-router-dom": { - "version": "7.12.0", - "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.12.0.tgz", - "integrity": "sha512-pfO9fiBcpEfX4Tx+iTYKDtPbrSLLCbwJ5EqP+SPYQu1VYCXdy79GSj0wttR0U4cikVdlImZuEZ/9ZNCgoaxwBA==", + "version": "7.14.1", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.14.1.tgz", + "integrity": "sha512-ZkrQuwwhGibjQLqH1eCdyiZyLWglPxzxdl5tgwgKEyCSGC76vmAjleGocRe3J/MLfzMUIKwaFJWpFVJhK3d2xA==", "license": "MIT", "dependencies": { - "react-router": "7.12.0" + "react-router": "7.14.1" }, "engines": { "node": ">=20.0.0" @@ -8957,9 +9059,9 @@ } }, "node_modules/react-syntax-highlighter": { - "version": "16.1.0", - "resolved": "https://registry.npmjs.org/react-syntax-highlighter/-/react-syntax-highlighter-16.1.0.tgz", - "integrity": "sha512-E40/hBiP5rCNwkeBN1vRP+xow1X0pndinO+z3h7HLsHyjztbyjfzNWNKuAsJj+7DLam9iT4AaaOZnueCU+Nplg==", + "version": "16.1.1", + "resolved": "https://registry.npmjs.org/react-syntax-highlighter/-/react-syntax-highlighter-16.1.1.tgz", + "integrity": "sha512-PjVawBGy80C6YbC5DDZJeUjBmC7skaoEUdvfFQediQHgCL7aKyVHe57SaJGfQsloGDac+gCpTfRdtxzWWKmCXA==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.28.4", @@ -9116,19 +9218,25 @@ } }, "node_modules/resolve": { - "version": "2.0.0-next.5", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.5.tgz", - "integrity": "sha512-U7WjGVG9sH8tvjW5SmGbQuui75FiyjAX72HX15DwBBwF9dNiQZRQAg9nnPhYy+TUnE0+VcrttuvNI8oSxZcocA==", + "version": "2.0.0-next.6", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.6.tgz", + "integrity": "sha512-3JmVl5hMGtJ3kMmB3zi3DL25KfkCEyy3Tw7Gmw7z5w8M9WlwoPFnIvwChzu1+cF3iaK3sp18hhPz8ANeimdJfA==", "dev": true, "license": "MIT", "dependencies": { - "is-core-module": "^2.13.0", + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", + "node-exports-info": "^1.6.0", + "object-keys": "^1.1.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" }, + "engines": { + "node": ">= 0.4" + }, "funding": { "url": "https://github.com/sponsors/ljharb" } @@ -9144,9 +9252,9 @@ } }, "node_modules/rettime": { - "version": "0.7.0", - "resolved": "https://registry.npmjs.org/rettime/-/rettime-0.7.0.tgz", - "integrity": "sha512-LPRKoHnLKd/r3dVxcwO7vhCW+orkOGj9ViueosEBK6ie89CijnfRlhaDhHq/3Hxu4CkWQtxwlBG0mzTQY6uQjw==", + "version": "0.11.7", + "resolved": "https://registry.npmjs.org/rettime/-/rettime-0.11.7.tgz", + "integrity": "sha512-DoAm1WjR1eH7z8sHPtvvUMIZh4/CSKkGCz6CxPqOrEAnOGtOuHSnSE9OC+razqxKuf4ub7pAYyl/vZV0vGs5tg==", "dev": true, "license": "MIT" }, @@ -9254,13 +9362,13 @@ "license": "MIT" }, "node_modules/rolldown": { - "version": "1.0.0-beta.50", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-beta.50.tgz", - "integrity": "sha512-JFULvCNl/anKn99eKjOSEubi0lLmNqQDAjyEMME2T4CwezUDL0i6t1O9xZsu2OMehPnV2caNefWpGF+8TnzB6A==", + "version": "1.0.0-beta.53", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-beta.53.tgz", + "integrity": "sha512-Qd9c2p0XKZdgT5AYd+KgAMggJ8ZmCs3JnS9PTMWkyUfteKlfmKtxJbWTHkVakxwXs1Ub7jrRYVeFeF7N0sQxyw==", "license": "MIT", "dependencies": { - "@oxc-project/types": "=0.97.0", - "@rolldown/pluginutils": "1.0.0-beta.50" + "@oxc-project/types": "=0.101.0", + "@rolldown/pluginutils": "1.0.0-beta.53" }, "bin": { "rolldown": "bin/cli.mjs" @@ -9269,26 +9377,25 @@ "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@rolldown/binding-android-arm64": "1.0.0-beta.50", - "@rolldown/binding-darwin-arm64": "1.0.0-beta.50", - "@rolldown/binding-darwin-x64": "1.0.0-beta.50", - "@rolldown/binding-freebsd-x64": "1.0.0-beta.50", - "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-beta.50", - "@rolldown/binding-linux-arm64-gnu": "1.0.0-beta.50", - "@rolldown/binding-linux-arm64-musl": "1.0.0-beta.50", - "@rolldown/binding-linux-x64-gnu": "1.0.0-beta.50", - "@rolldown/binding-linux-x64-musl": "1.0.0-beta.50", - "@rolldown/binding-openharmony-arm64": "1.0.0-beta.50", - "@rolldown/binding-wasm32-wasi": "1.0.0-beta.50", - "@rolldown/binding-win32-arm64-msvc": "1.0.0-beta.50", - "@rolldown/binding-win32-ia32-msvc": "1.0.0-beta.50", - "@rolldown/binding-win32-x64-msvc": "1.0.0-beta.50" + "@rolldown/binding-android-arm64": "1.0.0-beta.53", + "@rolldown/binding-darwin-arm64": "1.0.0-beta.53", + "@rolldown/binding-darwin-x64": "1.0.0-beta.53", + "@rolldown/binding-freebsd-x64": "1.0.0-beta.53", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-beta.53", + "@rolldown/binding-linux-arm64-gnu": "1.0.0-beta.53", + "@rolldown/binding-linux-arm64-musl": "1.0.0-beta.53", + "@rolldown/binding-linux-x64-gnu": "1.0.0-beta.53", + "@rolldown/binding-linux-x64-musl": "1.0.0-beta.53", + "@rolldown/binding-openharmony-arm64": "1.0.0-beta.53", + "@rolldown/binding-wasm32-wasi": "1.0.0-beta.53", + "@rolldown/binding-win32-arm64-msvc": "1.0.0-beta.53", + "@rolldown/binding-win32-x64-msvc": "1.0.0-beta.53" } }, "node_modules/rolldown/node_modules/@rolldown/pluginutils": { - "version": "1.0.0-beta.50", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.50.tgz", - "integrity": "sha512-5e76wQiQVeL1ICOZVUg4LSOVYg9jyhGCin+icYozhsUzM+fHE7kddi1bdiE0jwVqTfkjba3jUFbEkoC9WkdvyA==", + "version": "1.0.0-beta.53", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.53.tgz", + "integrity": "sha512-vENRlFU4YbrwVqNDZ7fLvy+JR1CRkyr01jhSiDpE1u6py3OMzQfztQU2jxykW3ALNxO4kSlqIDeYyD0Y9RcQeQ==", "license": "MIT" }, "node_modules/run-parallel": { @@ -9400,9 +9507,9 @@ } }, "node_modules/semver": { - "version": "7.7.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", - "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", "dev": true, "license": "ISC", "bin": { @@ -9563,14 +9670,14 @@ } }, "node_modules/side-channel-list": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", - "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", - "object-inspect": "^1.13.3" + "object-inspect": "^1.13.4" }, "engines": { "node": ">= 0.4" @@ -9629,6 +9736,7 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, "license": "ISC", "engines": { "node": ">=14" @@ -9739,6 +9847,7 @@ "version": "5.1.2", "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dev": true, "license": "MIT", "dependencies": { "eastasianwidth": "^0.2.0", @@ -9757,6 +9866,7 @@ "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, "license": "MIT", "dependencies": { "emoji-regex": "^8.0.0", @@ -9771,12 +9881,14 @@ "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, "license": "MIT" }, "node_modules/string-width-cjs/node_modules/strip-ansi": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, "license": "MIT", "dependencies": { "ansi-regex": "^5.0.1" @@ -9884,12 +9996,13 @@ } }, "node_modules/strip-ansi": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", - "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, "license": "MIT", "dependencies": { - "ansi-regex": "^6.0.1" + "ansi-regex": "^6.2.2" }, "engines": { "node": ">=12" @@ -9903,6 +10016,7 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, "license": "MIT", "dependencies": { "ansi-regex": "^5.0.1" @@ -9915,6 +10029,7 @@ "version": "6.2.2", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, "license": "MIT", "engines": { "node": ">=12" @@ -9970,17 +10085,17 @@ "license": "MIT" }, "node_modules/sucrase": { - "version": "3.35.0", - "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.0.tgz", - "integrity": "sha512-8EbVDiu9iN/nESwxeSxDKe0dunta1GOlHufmSSXxMD2z2/tMZpDMpvXQGsc+ajGo8y2uYUmixaSRUc/QPoQ0GA==", + "version": "3.35.1", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", + "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", "license": "MIT", "dependencies": { "@jridgewell/gen-mapping": "^0.3.2", "commander": "^4.0.0", - "glob": "^10.3.10", "lines-and-columns": "^1.1.6", "mz": "^2.7.0", "pirates": "^4.0.1", + "tinyglobby": "^0.2.11", "ts-interface-checker": "^0.1.9" }, "bin": { @@ -10017,9 +10132,9 @@ } }, "node_modules/synckit": { - "version": "0.11.11", - "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.11.11.tgz", - "integrity": "sha512-MeQTA1r0litLUf0Rp/iisCaL8761lKAZHaimlbGK4j0HysC4PLfqygQj9srcs0m2RdtDYnF8UuYyKpbjHYp7Jw==", + "version": "0.11.12", + "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.11.12.tgz", + "integrity": "sha512-Bh7QjT8/SuKUIfObSXNHNSK6WHo6J1tHCqJsuaFDP7gP0fkzSfTxI8y85JrppZ0h8l0maIgc2tfuZQ6/t3GtnQ==", "dev": true, "license": "MIT", "dependencies": { @@ -10033,11 +10148,24 @@ } }, "node_modules/tabbable": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.3.0.tgz", - "integrity": "sha512-EIHvdY5bPLuWForiR/AN2Bxngzpuwn1is4asboytXtpTgsArc+WmSJKVLlhdh71u7jFcryDqB2A8lQvj78MkyQ==", + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.4.0.tgz", + "integrity": "sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg==", "license": "MIT" }, + "node_modules/tagged-tag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/tagged-tag/-/tagged-tag-1.0.0.tgz", + "integrity": "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/tailwind-merge": { "version": "1.8.1", "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-1.8.1.tgz", @@ -10045,9 +10173,9 @@ "license": "MIT" }, "node_modules/tailwindcss": { - "version": "3.4.18", - "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.18.tgz", - "integrity": "sha512-6A2rnmW5xZMdw11LYjhcI5846rt9pbLSabY5XPxo+XWdxwZaFEn47Go4NzFiHu9sNNmr/kXivP1vStfvMaK1GQ==", + "version": "3.4.19", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz", + "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==", "license": "MIT", "dependencies": { "@alloc/quick-lru": "^5.2.0", @@ -10082,11 +10210,12 @@ } }, "node_modules/tailwindcss/node_modules/resolve": { - "version": "1.22.11", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", - "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", + "version": "1.22.12", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", + "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", "license": "MIT", "dependencies": { + "es-errors": "^1.3.0", "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" @@ -10102,15 +10231,15 @@ } }, "node_modules/test-exclude": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-7.0.1.tgz", - "integrity": "sha512-pFYqmTw68LXVjeWJMST4+borgQP2AyMNbg1BpZh9LbyhUeNkeaPF9gzfPGUAnSMV3qPYdWUwDIjjCLiSDOl7vg==", + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-7.0.2.tgz", + "integrity": "sha512-u9E6A+ZDYdp7a4WnarkXPZOx8Ilz46+kby6p1yZ8zsGTz9gYa6FIS7lj2oezzNKmtdyyJNNmmXDppga5GB7kSw==", "dev": true, "license": "ISC", "dependencies": { "@istanbuljs/schema": "^0.1.2", "glob": "^10.4.1", - "minimatch": "^9.0.4" + "minimatch": "^10.2.2" }, "engines": { "node": ">=18" @@ -10165,13 +10294,13 @@ "license": "MIT" }, "node_modules/tinyglobby": { - "version": "0.2.15", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", - "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", "license": "MIT", "dependencies": { "fdir": "^6.5.0", - "picomatch": "^4.0.3" + "picomatch": "^4.0.4" }, "engines": { "node": ">=12.0.0" @@ -10240,22 +10369,22 @@ } }, "node_modules/tldts": { - "version": "7.0.18", - "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.18.tgz", - "integrity": "sha512-lCcgTAgMxQ1JKOWrVGo6E69Ukbnx4Gc1wiYLRf6J5NN4HRYJtCby1rPF8rkQ4a6qqoFBK5dvjJ1zJ0F7VfDSvw==", + "version": "7.0.28", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.28.tgz", + "integrity": "sha512-+Zg3vWhRUv8B1maGSTFdev9mjoo8Etn2Ayfs4cnjlD3CsGkxXX4QyW3j2WJ0wdjYcYmy7Lx2RDsZMhgCWafKIw==", "dev": true, "license": "MIT", "dependencies": { - "tldts-core": "^7.0.18" + "tldts-core": "^7.0.28" }, "bin": { "tldts": "bin/cli.js" } }, "node_modules/tldts-core": { - "version": "7.0.18", - "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.18.tgz", - "integrity": "sha512-jqJC13oP4FFAahv4JT/0WTDrCF9Okv7lpKtOZUGPLiAnNbACcSg8Y8T+Z9xthOmRBqi/Sob4yi0TE0miRCvF7Q==", + "version": "7.0.28", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.28.tgz", + "integrity": "sha512-7W5Efjhsc3chVdFhqtaU0KtK32J37Zcr9RKtID54nG+tIpcY79CQK/veYPODxtD/LJ4Lue66jvrQzIX2Z2/pUQ==", "dev": true, "license": "MIT" }, @@ -10287,9 +10416,9 @@ } }, "node_modules/tough-cookie": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.0.tgz", - "integrity": "sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w==", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.1.tgz", + "integrity": "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==", "dev": true, "license": "BSD-3-Clause", "dependencies": { @@ -10300,9 +10429,9 @@ } }, "node_modules/ts-api-utils": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz", - "integrity": "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==", + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", "dev": true, "license": "MIT", "engines": { @@ -10346,13 +10475,16 @@ } }, "node_modules/type-fest": { - "version": "4.41.0", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", - "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.5.0.tgz", + "integrity": "sha512-PlBfpQwiUvGViBNX84Yxwjsdhd1TUlXr6zjX7eoirtCPIr08NAmxwa+fcYBTeRQxHo9YC9wwF3m9i700sHma8g==", "dev": true, "license": "(MIT OR CC0-1.0)", + "dependencies": { + "tagged-tag": "^1.0.0" + }, "engines": { - "node": ">=16" + "node": ">=20" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" @@ -10450,16 +10582,16 @@ } }, "node_modules/typescript-eslint": { - "version": "8.47.0", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.47.0.tgz", - "integrity": "sha512-Lwe8i2XQ3WoMjua/r1PHrCTpkubPYJCAfOurtn+mtTzqB6jNd+14n9UN1bJ4s3F49x9ixAm0FLflB/JzQ57M8Q==", + "version": "8.58.2", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.58.2.tgz", + "integrity": "sha512-V8iSng9mRbdZjl54VJ9NKr6ZB+dW0J3TzRXRGcSbLIej9jV86ZRtlYeTKDR/QLxXykocJ5icNzbsl2+5TzIvcQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/eslint-plugin": "8.47.0", - "@typescript-eslint/parser": "8.47.0", - "@typescript-eslint/typescript-estree": "8.47.0", - "@typescript-eslint/utils": "8.47.0" + "@typescript-eslint/eslint-plugin": "8.58.2", + "@typescript-eslint/parser": "8.58.2", + "@typescript-eslint/typescript-estree": "8.58.2", + "@typescript-eslint/utils": "8.58.2" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -10469,8 +10601,8 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <6.0.0" + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/unbox-primitive": { @@ -10493,9 +10625,9 @@ } }, "node_modules/undici-types": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", - "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "version": "7.19.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.19.2.tgz", + "integrity": "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==", "devOptional": true, "license": "MIT" }, @@ -10528,9 +10660,9 @@ } }, "node_modules/unist-util-visit": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.0.0.tgz", - "integrity": "sha512-MR04uvD+07cwl/yhVuVWAtw+3GOR/knlL55Nd/wAdblk27GCVt3lqpTivy/tkJcZoNPzTwS1Y+KMojlLDhoTzg==", + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.1.0.tgz", + "integrity": "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==", "dev": true, "license": "MIT", "dependencies": { @@ -10581,9 +10713,9 @@ } }, "node_modules/update-browserslist-db": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.4.tgz", - "integrity": "sha512-q0SPT4xyU84saUX+tomz1WLkxUbuaJnR1xWt17M7fJtEJigJeWUNGUqrauFXsHnqev9y9JTRGwk13tFBuKby4A==", + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", "funding": [ { "type": "opencollective", @@ -10678,17 +10810,18 @@ }, "node_modules/vite": { "name": "rolldown-vite", - "version": "7.2.5", - "resolved": "https://registry.npmjs.org/rolldown-vite/-/rolldown-vite-7.2.5.tgz", - "integrity": "sha512-u09tdk/huMiN8xwoiBbig197jKdCamQTtOruSalOzbqGje3jdHiV0njQlAW0YvzoahkirFePNQ4RYlfnRQpXZA==", + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/rolldown-vite/-/rolldown-vite-7.3.1.tgz", + "integrity": "sha512-LYzdNAjRHhF2yA4JUQm/QyARyi216N2rpJ0lJZb8E9FU2y5v6Vk+xq/U4XBOxMefpWixT5H3TslmAHm1rqIq2w==", + "deprecated": "Use this package to migrate from Vite 7 to Vite 8. For the most recent updates, migrate to Vite 8 once you're ready.", "license": "MIT", "dependencies": { - "@oxc-project/runtime": "0.97.0", + "@oxc-project/runtime": "0.101.0", "fdir": "^6.5.0", "lightningcss": "^1.30.2", "picomatch": "^4.0.3", "postcss": "^8.5.6", - "rolldown": "1.0.0-beta.50", + "rolldown": "1.0.0-beta.53", "tinyglobby": "^0.2.15" }, "bin": { @@ -10705,7 +10838,7 @@ }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", - "esbuild": "^0.25.0", + "esbuild": "^0.27.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", @@ -11007,9 +11140,9 @@ } }, "node_modules/which-typed-array": { - "version": "1.1.19", - "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.19.tgz", - "integrity": "sha512-rEvr90Bck4WZt9HHFC4DJMsjvu7x+r6bImz0/BrbWb7A2djJ8hnZMrWnHo9F8ssv0OMErasDhftrfROTyqSDrw==", + "version": "1.1.20", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.20.tgz", + "integrity": "sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg==", "dev": true, "license": "MIT", "dependencies": { @@ -11075,6 +11208,7 @@ "version": "7.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, "license": "MIT", "dependencies": { "ansi-styles": "^4.0.0", @@ -11092,12 +11226,14 @@ "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, "license": "MIT" }, "node_modules/wrap-ansi-cjs/node_modules/string-width": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, "license": "MIT", "dependencies": { "emoji-regex": "^8.0.0", @@ -11112,6 +11248,7 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, "license": "MIT", "dependencies": { "ansi-regex": "^5.0.1" From 1f3b7e5d609e001a23303d29f6cfa9a1a7c13a57 Mon Sep 17 00:00:00 2001 From: Allison Truhlar Date: Wed, 15 Apr 2026 13:45:08 -0400 Subject: [PATCH 100/104] feat: add /api/viewers-config endpoint for runtime viewer configuration allows override of viewers config at runtime via FGC_VIEWERS_CONFIG --- fileglancer/server.py | 16 ++++++++ fileglancer/settings.py | 5 +++ tests/test_endpoints.py | 83 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 104 insertions(+) diff --git a/fileglancer/server.py b/fileglancer/server.py index c761acf2d..3f07bf46f 100644 --- a/fileglancer/server.py +++ b/fileglancer/server.py @@ -442,6 +442,22 @@ async def version_endpoint(): return {"version": APP_VERSION} + @app.get("/api/viewers-config", include_in_schema=False) + async def get_viewers_config(): + if not settings.viewers_config: + raise HTTPException(status_code=404, detail="No viewers configuration") + + config_path = PathLib(settings.viewers_config) + if not config_path.exists() or not config_path.is_file(): + logger.warning(f"Viewers config file not found: {settings.viewers_config}") + raise HTTPException(status_code=404, detail="Viewers configuration file not found") + + return PlainTextResponse( + content=config_path.read_text(encoding="utf-8"), + media_type="text/yaml" + ) + + # Authentication routes @app.get("/api/auth/login", include_in_schema=settings.enable_okta_auth, description="Initiate OKTA OAuth login flow") diff --git a/fileglancer/settings.py b/fileglancer/settings.py index 3b21bdef8..8d05e69a2 100644 --- a/fileglancer/settings.py +++ b/fileglancer/settings.py @@ -113,6 +113,11 @@ class Settings(BaseSettings): # Username used when creating a session via the test-login endpoint. test_login_username: str = "jacs" + # Optional path to a viewers configuration YAML file. + # When set, the file is served at GET /api/viewers-config, allowing + # runtime customization of OME-Zarr viewers without rebuilding the frontend. + viewers_config: Optional[str] = None + model_config = SettingsConfigDict( yaml_file="config.yaml", env_file='.env', diff --git a/tests/test_endpoints.py b/tests/test_endpoints.py index 11f4c5bfd..c730ca260 100644 --- a/tests/test_endpoints.py +++ b/tests/test_endpoints.py @@ -1426,3 +1426,86 @@ def test_broken_symlink_in_file_listing(test_client, temp_dir): regular = next((f for f in files if f["name"] == "regular.txt"), None) assert regular is not None, "Regular file should be in response" assert regular["is_symlink"] is False, "Regular file should not be marked as symlink" + + +def test_viewers_config_not_set(test_client): + """Test that /api/viewers-config returns 404 when viewers_config is not set""" + response = test_client.get("/api/viewers-config") + assert response.status_code == 404 + + +def test_viewers_config_file_exists(temp_dir): + """Test that /api/viewers-config returns file contents as text/yaml when file exists""" + # Create a test viewers config file + config_content = "viewers:\n - manifest_url: 'https://example.com/manifest.yaml'\n" + config_path = os.path.join(temp_dir, "viewers.config.yaml") + with open(config_path, 'w') as f: + f.write(config_content) + + # Create app with viewers_config set + db_path = os.path.join(temp_dir, "test_vc.db") + db_url = f"sqlite:///{db_path}" + engine = create_engine(db_url) + Session = sessionmaker(bind=engine) + db_session = Session() + Base.metadata.create_all(engine) + + settings = Settings(db_url=db_url, file_share_mounts=[], viewers_config=config_path) + + import fileglancer.settings + import fileglancer.database + original_get_settings = fileglancer.settings.get_settings + fileglancer.settings.get_settings = lambda: settings + fileglancer.database.get_settings = lambda: settings + + app = create_app(settings) + from fileglancer.server import get_current_user + app.dependency_overrides[get_current_user] = lambda: "testuser" + client = TestClient(app) + + try: + response = client.get("/api/viewers-config") + assert response.status_code == 200 + assert "text/yaml" in response.headers["content-type"] + assert response.text == config_content + finally: + db_session.close() + engine.dispose() + from fileglancer.database import dispose_engine + dispose_engine(db_url) + fileglancer.settings.get_settings = original_get_settings + fileglancer.database.get_settings = original_get_settings + + +def test_viewers_config_file_missing(temp_dir): + """Test that /api/viewers-config returns 404 when configured file doesn't exist""" + db_path = os.path.join(temp_dir, "test_vcm.db") + db_url = f"sqlite:///{db_path}" + engine = create_engine(db_url) + Session = sessionmaker(bind=engine) + db_session = Session() + Base.metadata.create_all(engine) + + settings = Settings(db_url=db_url, file_share_mounts=[], viewers_config="/nonexistent/viewers.config.yaml") + + import fileglancer.settings + import fileglancer.database + original_get_settings = fileglancer.settings.get_settings + fileglancer.settings.get_settings = lambda: settings + fileglancer.database.get_settings = lambda: settings + + app = create_app(settings) + from fileglancer.server import get_current_user + app.dependency_overrides[get_current_user] = lambda: "testuser" + client = TestClient(app) + + try: + response = client.get("/api/viewers-config") + assert response.status_code == 404 + finally: + db_session.close() + engine.dispose() + from fileglancer.database import dispose_engine + dispose_engine(db_url) + fileglancer.settings.get_settings = original_get_settings + fileglancer.database.get_settings = original_get_settings From dccbcf727dbdca5aa514f4f0780e571779e3cb0e Mon Sep 17 00:00:00 2001 From: Allison Truhlar Date: Wed, 15 Apr 2026 13:46:24 -0400 Subject: [PATCH 101/104] refactor: load viewers config via React Query with runtime fallback this allows caching of the viewers config, and also conforms to existing code base patterns --- .../componentTests/DataToolLinks.test.tsx | 96 +++++++++---------- frontend/src/__tests__/mocks/handlers.ts | 5 + frontend/src/contexts/ViewersContext.tsx | 65 +++++-------- frontend/src/queries/viewersConfigQueries.ts | 50 ++++++++++ 4 files changed, 126 insertions(+), 90 deletions(-) create mode 100644 frontend/src/queries/viewersConfigQueries.ts diff --git a/frontend/src/__tests__/componentTests/DataToolLinks.test.tsx b/frontend/src/__tests__/componentTests/DataToolLinks.test.tsx index 1098b7d2f..82610c34b 100644 --- a/frontend/src/__tests__/componentTests/DataToolLinks.test.tsx +++ b/frontend/src/__tests__/componentTests/DataToolLinks.test.tsx @@ -196,29 +196,29 @@ describe('DataToolLinks - Edge Cases', () => { // Test that viewers with known logo files render correctly in the component renderDataToolLinks(); + // Wait for viewer logos to load (async: config query + manifest loading) await waitFor( () => { - expect(screen.getByText('Test Tools')).toBeInTheDocument(); + const images = screen.getAllByRole('img'); + + // Check for neuroglancer logo (known viewer with logo) + const neuroglancerLogo = images.find( + img => img.getAttribute('alt') === 'View in Neuroglancer' + ); + expect(neuroglancerLogo).toBeTruthy(); + expect(neuroglancerLogo?.getAttribute('src')).toContain( + 'neuroglancer' + ); + + // Check for avivator logo (name for viewer in avivator.yaml) + const vizarrLogo = images.find( + img => img.getAttribute('alt') === 'View in Avivator' + ); + expect(vizarrLogo).toBeTruthy(); + expect(vizarrLogo?.getAttribute('src')).toContain('avivator'); }, { timeout: 3000 } ); - - // Check that images are rendered - const images = screen.getAllByRole('img'); - - // Check for neuroglancer logo (known viewer with logo) - const neuroglancerLogo = images.find( - img => img.getAttribute('alt') === 'View in Neuroglancer' - ); - expect(neuroglancerLogo).toBeTruthy(); - expect(neuroglancerLogo?.getAttribute('src')).toContain('neuroglancer'); - - // Check for avivator logo (name for viewer in avivator.yaml) - const vizarrLogo = images.find( - img => img.getAttribute('alt') === 'View in Avivator' - ); - expect(vizarrLogo).toBeTruthy(); - expect(vizarrLogo?.getAttribute('src')).toContain('avivator'); }); }); @@ -232,20 +232,18 @@ describe('DataToolLinks - Edge Cases', () => { renderDataToolLinks(urls); + // Wait for viewer logos to load asynchronously await waitFor( () => { - expect(screen.getByText('Test Tools')).toBeInTheDocument(); + const images = screen.getAllByRole('img'); + const neuroglancerLogo = images.find( + img => img.getAttribute('alt') === 'View in Neuroglancer' + ); + expect(neuroglancerLogo).toBeTruthy(); }, { timeout: 3000 } ); - // Should have neuroglancer logo - const images = screen.getAllByRole('img'); - const neuroglancerLogo = images.find( - img => img.getAttribute('alt') === 'View in Neuroglancer' - ); - expect(neuroglancerLogo).toBeTruthy(); - // Copy button is an SVG icon, find by aria-label const copyButton = screen.getByLabelText('Copy data URL'); expect(copyButton).toBeInTheDocument(); @@ -311,17 +309,15 @@ describe('DataToolLinks - Expected Behavior', () => { it('should render valid viewer icons and copy icon', async () => { renderDataToolLinks(); + // Wait for viewer logos to load asynchronously await waitFor( () => { - expect(screen.getByText('Test Tools')).toBeInTheDocument(); + const images = screen.getAllByRole('img'); + expect(images.length).toBeGreaterThanOrEqual(1); }, { timeout: 3000 } ); - // Should render viewer logos - const images = screen.getAllByRole('img'); - expect(images.length).toBeGreaterThanOrEqual(1); - // Copy button is an SVG icon, find by aria-label const copyButton = screen.getByLabelText('Copy data URL'); expect(copyButton).toBeInTheDocument(); @@ -352,30 +348,28 @@ describe('DataToolLinks - Expected Behavior', () => { it('should render multiple viewer logos when URLs are provided', async () => { renderDataToolLinks(); + // Wait for viewer logos to load asynchronously await waitFor( () => { - expect(screen.getByText('Test Tools')).toBeInTheDocument(); + const images = screen.getAllByRole('img'); + expect(images.length).toBeGreaterThanOrEqual(2); + + const neuroglancerLogo = images.find( + img => img.getAttribute('alt') === 'View in Neuroglancer' + ); + const vizarrLogo = images.find( + img => img.getAttribute('alt') === 'View in Avivator' + ); + + expect(neuroglancerLogo).toBeTruthy(); + expect(vizarrLogo).toBeTruthy(); }, { timeout: 3000 } ); - // Verify viewer logos are present (img elements) - const images = screen.getAllByRole('img'); - expect(images.length).toBeGreaterThanOrEqual(2); - - const neuroglancerLogo = images.find( - img => img.getAttribute('alt') === 'View in Neuroglancer' - ); - const vizarrLogo = images.find( - img => img.getAttribute('alt') === 'View in Avivator' - ); - // Copy icon is an SVG, not an img — verify separately const copyButton = screen.getByLabelText('Copy data URL'); expect(copyButton).toBeInTheDocument(); - - expect(neuroglancerLogo).toBeTruthy(); - expect(vizarrLogo).toBeTruthy(); }); }); @@ -398,17 +392,17 @@ describe('DataToolLinks - Expected Behavior', () => { it('should show viewer tooltip labels', async () => { renderDataToolLinks(); + // Wait for viewer logos to load asynchronously await waitFor( () => { - expect(screen.getByText('Test Tools')).toBeInTheDocument(); + const neuroglancerButton = screen.getByAltText( + 'View in Neuroglancer' + ); + expect(neuroglancerButton).toBeInTheDocument(); }, { timeout: 3000 } ); - // Viewer buttons should have correct aria-labels from their config - const neuroglancerButton = screen.getByAltText('View in Neuroglancer'); - expect(neuroglancerButton).toBeInTheDocument(); - const vizarrButton = screen.getByAltText('View in Avivator'); expect(vizarrButton).toBeInTheDocument(); }); diff --git a/frontend/src/__tests__/mocks/handlers.ts b/frontend/src/__tests__/mocks/handlers.ts index 609be7049..926928f27 100644 --- a/frontend/src/__tests__/mocks/handlers.ts +++ b/frontend/src/__tests__/mocks/handlers.ts @@ -281,6 +281,11 @@ export const handlers = [ return HttpResponse.json({ authenticated: true }); }), + // Viewers config - 404 means no runtime config, fall through to bundled default + http.get('/api/viewers-config', () => { + return HttpResponse.json(null, { status: 404 }); + }), + // File content for Zarr metadata files http.get('/api/content/:fspName', ({ params, request }) => { const url = new URL(request.url); diff --git a/frontend/src/contexts/ViewersContext.tsx b/frontend/src/contexts/ViewersContext.tsx index 236e87f6a..3a109e56e 100644 --- a/frontend/src/contexts/ViewersContext.tsx +++ b/frontend/src/contexts/ViewersContext.tsx @@ -14,10 +14,7 @@ import { type OmeZarrMetadata } from '@bioimagetools/capability-manifest'; import { default as log } from '@/logger'; -import { - parseViewersConfig, - type ViewerConfigEntry -} from '@/config/viewersConfig'; +import { useViewersConfigQuery } from '@/queries/viewersConfigQueries'; /** * Validated viewer with all necessary information @@ -46,31 +43,6 @@ interface ViewersContextType { const ViewersContext = createContext(undefined); -/** - * Load viewers configuration from build-time config file - */ -async function loadViewersConfig(): Promise { - let configYaml: string; - - try { - const module = await import('@/config/viewers.config.yaml?raw'); - configYaml = module.default; - } catch (error) { - throw new Error( - `Failed to load viewers configuration: ${error instanceof Error ? error.message : 'Unknown error'}` - ); - } - - try { - const config = parseViewersConfig(configYaml); - return config.viewers; - } catch (error) { - throw new Error( - `Failed to parse viewers configuration: ${error instanceof Error ? error.message : 'Unknown error'}` - ); - } -} - /** * Normalize viewer name to a valid key */ @@ -90,17 +62,22 @@ export function ViewersProvider({ const [isInitialized, setIsInitialized] = useState(false); const [error, setError] = useState(null); + const { + data: configEntries, + isError: isConfigError, + error: configError + } = useViewersConfigQuery(); + useEffect(() => { - async function initialize() { - try { - log.info('Initializing viewers configuration...'); + if (!configEntries) return; + const entries = configEntries; - // Load viewer config entries - const configEntries = await loadViewersConfig(); - log.info(`Loaded configuration for ${configEntries.length} viewers`); + async function loadManifests() { + try { + log.info(`Loaded configuration for ${entries.length} viewers`); // Extract manifest URLs - const manifestUrls = configEntries.map(entry => entry.manifest_url); + const manifestUrls = entries.map(entry => entry.manifest_url); // Load capability manifests (with a 10s timeout to avoid hanging on unreachable URLs) let manifestsMap: Map; @@ -123,7 +100,7 @@ export function ViewersProvider({ const validated: ValidViewer[] = []; // Map through viewer config entries to validate - for (const entry of configEntries) { + for (const entry of entries) { const manifest = manifestsMap.get(entry.manifest_url); if (!manifest) { @@ -192,8 +169,18 @@ export function ViewersProvider({ } } - initialize(); - }, []); + loadManifests(); + }, [configEntries]); + + // Handle query-level errors + useEffect(() => { + if (isConfigError && configError) { + const errorMessage = configError.message; + log.error('Failed to load viewers configuration:', errorMessage); + setError(errorMessage); + setIsInitialized(true); + } + }, [isConfigError, configError]); const getViewersCompatibleWithImage = useCallback( (metadata: OmeZarrMetadata): ValidViewer[] => { diff --git a/frontend/src/queries/viewersConfigQueries.ts b/frontend/src/queries/viewersConfigQueries.ts new file mode 100644 index 000000000..a48f6c82e --- /dev/null +++ b/frontend/src/queries/viewersConfigQueries.ts @@ -0,0 +1,50 @@ +import { useQuery } from '@tanstack/react-query'; +import type { UseQueryResult } from '@tanstack/react-query'; +import { sendFetchRequest } from '@/utils'; +import { default as log } from '@/logger'; +import { + parseViewersConfig, + type ViewerConfigEntry +} from '@/config/viewersConfig'; + +export const viewersConfigKeys = { + all: ['viewersConfig'] as const +}; + +const fetchViewersConfig = async (): Promise => { + // Try runtime config from the server first. + try { + const response = await sendFetchRequest('/api/viewers-config', 'GET'); + if (response.ok) { + const configYaml = await response.text(); + const config = parseViewersConfig(configYaml); + log.info('Using runtime viewers configuration from server'); + return config.viewers; + } else if (response.status !== 404) { + log.warn( + `Unexpected status ${response.status} from /api/viewers-config, falling back to bundled config` + ); + } + // 404 means no runtime config — fall through to bundled default + } catch { + // Network error — fall through to bundled default + log.info('Runtime viewers config not available, using bundled default'); + } + + // Fall back to build-time bundled config + const module = await import('@/config/viewers.config.yaml?raw'); + const config = parseViewersConfig(module.default); + return config.viewers; +}; + +export function useViewersConfigQuery(): UseQueryResult< + ViewerConfigEntry[], + Error +> { + return useQuery({ + queryKey: viewersConfigKeys.all, + queryFn: fetchViewersConfig, + staleTime: Infinity, // Config won't change during a session + retry: false // If both sources fail, don't retry + }); +} From e8f295173526c5a06a5b469231a6315d1394aacd Mon Sep 17 00:00:00 2001 From: Allison Truhlar Date: Wed, 15 Apr 2026 13:46:43 -0400 Subject: [PATCH 102/104] docs: document runtime viewers configuration --- docs/ViewersConfiguration.md | 33 ++++++++++++++++++++++--- frontend/src/config/viewers.config.yaml | 4 +++ 2 files changed, 34 insertions(+), 3 deletions(-) diff --git a/docs/ViewersConfiguration.md b/docs/ViewersConfiguration.md index 55b7a9353..52542957f 100644 --- a/docs/ViewersConfiguration.md +++ b/docs/ViewersConfiguration.md @@ -21,20 +21,47 @@ Each viewer is defined by a **capability manifest** hosted at a URL. The configu 2. Edit `frontend/viewers.config.yaml` to customize viewers 3. Rebuild the application: `pixi run node-build` +## Runtime Configuration (System Deployments) + +For deployments where Fileglancer is installed from PyPI and the frontend is pre-built, you can override the viewers configuration at runtime without rebuilding. + +Set the `FGC_VIEWERS_CONFIG` environment variable (or `viewers_config` in `config.yaml`) to the absolute path of a `viewers.config.yaml` file on disk: + +```env +FGC_VIEWERS_CONFIG=/opt/deploy/viewers.config.yaml +``` + +Or in `config.yaml`: + +```yaml +viewers_config: /opt/deploy/viewers.config.yaml +``` + +When set, the application serves this file via the API and the frontend uses it instead of the bundled config. The file follows the same format as the build-time `viewers.config.yaml`. + +### Precedence + +The viewers configuration is resolved in the following order (highest priority first): + +1. **Runtime API config** — served from the path in `FGC_VIEWERS_CONFIG` (no rebuild required) +2. **Build-time override** — `frontend/viewers.config.yaml` (requires rebuild) +3. **Build-time default** — `frontend/src/config/viewers.config.yaml` (requires rebuild) + ## Configuration File ### Location -There are two config locations, with the `frontend/` override taking precedence at build time: +There are three config locations, resolved in order of precedence: | Location | Purpose | | -------- | ------- | -| `frontend/viewers.config.yaml` | **Local override** — gitignored, safe to customize without merge conflicts | +| Path in `FGC_VIEWERS_CONFIG` | **Runtime override** — served via API, no rebuild required; ideal for system deployments | +| `frontend/viewers.config.yaml` | **Build-time override** — gitignored, safe to customize without merge conflicts | | `frontend/src/config/viewers.config.yaml` | **Default config** — committed source file, used when no override exists | Copy `frontend/src/config/viewers.config.yaml` to `frontend/viewers.config.yaml` to create a local override. This file is listed in `.gitignore` so your customizations will not conflict with upstream updates. -**Important:** The config is bundled at build time. Changes require rebuilding the application. +**Important:** The build-time configs are bundled at build time and changes require rebuilding the application. Runtime config via `FGC_VIEWERS_CONFIG` does **not** require rebuilding. ### Structure diff --git a/frontend/src/config/viewers.config.yaml b/frontend/src/config/viewers.config.yaml index 8a0400bd4..a869702e7 100644 --- a/frontend/src/config/viewers.config.yaml +++ b/frontend/src/config/viewers.config.yaml @@ -6,6 +6,10 @@ # After editing, rebuild with: pixi run node-build # (or use watch mode: pixi run dev-watch) # +# For system deployments (e.g. installed from PyPI), you can also set +# FGC_VIEWERS_CONFIG=/path/to/viewers.config.yaml to override viewers at +# runtime without rebuilding. See docs/ViewersConfiguration.md for details. +# # Each viewer entry requires: # - manifest_url: URL or absolute path to a capability manifest YAML file # Use absolute paths (e.g. /viewers/neuroglancer.yaml) for manifests bundled From be00fdddccfa24f5ea8bcce4246986754612ff53 Mon Sep 17 00:00:00 2001 From: Allison Truhlar Date: Wed, 15 Apr 2026 13:47:10 -0400 Subject: [PATCH 103/104] fixes: prettier formatting and using logger instead of console.error --- frontend/src/components/ui/BrowsePage/FileBrowser.tsx | 6 +++--- frontend/src/components/ui/BrowsePage/ZarrMetadataTable.tsx | 3 ++- frontend/src/components/ui/Dialogs/ChangePermissions.tsx | 3 +-- .../src/components/ui/PropertiesDrawer/PropertiesDrawer.tsx | 2 +- frontend/src/hooks/useZarrMetadata.ts | 2 +- 5 files changed, 8 insertions(+), 8 deletions(-) diff --git a/frontend/src/components/ui/BrowsePage/FileBrowser.tsx b/frontend/src/components/ui/BrowsePage/FileBrowser.tsx index b24a2a14d..149071e07 100644 --- a/frontend/src/components/ui/BrowsePage/FileBrowser.tsx +++ b/frontend/src/components/ui/BrowsePage/FileBrowser.tsx @@ -126,9 +126,9 @@ export default function FileBrowser({ const propertiesTarget = fileBrowserState.propertiesTarget; const isFavorite = Boolean( fspName && - folderPreferenceMap[ - makeMapKey('folder', `${fspName}_${propertiesTarget.path}`) - ] + folderPreferenceMap[ + makeMapKey('folder', `${fspName}_${propertiesTarget.path}`) + ] ); return [ diff --git a/frontend/src/components/ui/BrowsePage/ZarrMetadataTable.tsx b/frontend/src/components/ui/BrowsePage/ZarrMetadataTable.tsx index efdfe203d..6edbf6c13 100644 --- a/frontend/src/components/ui/BrowsePage/ZarrMetadataTable.tsx +++ b/frontend/src/components/ui/BrowsePage/ZarrMetadataTable.tsx @@ -1,6 +1,7 @@ import * as zarr from 'zarrita'; import { Axis } from 'ome-zarr.js'; import { HiQuestionMarkCircle } from 'react-icons/hi'; +import { default as log } from '@/logger'; import { usePreferencesContext } from '@/contexts/PreferencesContext'; import { @@ -58,7 +59,7 @@ function getAxisData(metadata: Metadata) { }; }); } catch (error) { - console.error('Error getting axis data: ', error); + log.error('Error getting axis data: ', error); return []; } } diff --git a/frontend/src/components/ui/Dialogs/ChangePermissions.tsx b/frontend/src/components/ui/Dialogs/ChangePermissions.tsx index 6994366d5..e51d5bb3d 100644 --- a/frontend/src/components/ui/Dialogs/ChangePermissions.tsx +++ b/frontend/src/components/ui/Dialogs/ChangePermissions.tsx @@ -153,8 +153,7 @@ export default function ChangePermissions({ className="!rounded-md" disabled={Boolean( mutations.changePermissions.isPending || - localPermissions === - fileBrowserState.propertiesTarget.permissions + localPermissions === fileBrowserState.propertiesTarget.permissions )} type="submit" > diff --git a/frontend/src/components/ui/PropertiesDrawer/PropertiesDrawer.tsx b/frontend/src/components/ui/PropertiesDrawer/PropertiesDrawer.tsx index b94e9bae4..6bf874969 100644 --- a/frontend/src/components/ui/PropertiesDrawer/PropertiesDrawer.tsx +++ b/frontend/src/components/ui/PropertiesDrawer/PropertiesDrawer.tsx @@ -279,7 +279,7 @@ export default function PropertiesDrawer({ } disabled={Boolean( externalDataUrlQuery.data || - fileBrowserState.propertiesTarget.hasRead === false + fileBrowserState.propertiesTarget.hasRead === false )} id="share-switch" label={ diff --git a/frontend/src/hooks/useZarrMetadata.ts b/frontend/src/hooks/useZarrMetadata.ts index f73b03f28..0e95d271b 100644 --- a/frontend/src/hooks/useZarrMetadata.ts +++ b/frontend/src/hooks/useZarrMetadata.ts @@ -83,7 +83,7 @@ export default function useZarrMetadata() { setLayerType(determinedLayerType); } catch (error) { if (!signal.aborted) { - console.error('Error determining layer type:', error); + log.error('Error determining layer type:', error); setLayerType('image'); // Default fallback } } From b441c56ef4aef64a67772f5723d3f0a579fcd6c0 Mon Sep 17 00:00:00 2001 From: Allison Truhlar Date: Wed, 15 Apr 2026 13:55:03 -0400 Subject: [PATCH 104/104] fix: prettier styling --- frontend/src/contexts/ViewersContext.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/frontend/src/contexts/ViewersContext.tsx b/frontend/src/contexts/ViewersContext.tsx index 3a109e56e..bff4bc007 100644 --- a/frontend/src/contexts/ViewersContext.tsx +++ b/frontend/src/contexts/ViewersContext.tsx @@ -69,7 +69,9 @@ export function ViewersProvider({ } = useViewersConfigQuery(); useEffect(() => { - if (!configEntries) return; + if (!configEntries) { + return; + } const entries = configEntries; async function loadManifests() {