1069 lines
40 KiB
TypeScript
1069 lines
40 KiB
TypeScript
/**
|
||
* LinkMLViewerPage.tsx - LinkML Schema Viewer Page
|
||
*
|
||
* Displays LinkML schema files with:
|
||
* - Sidebar listing schemas by category (main, classes, enums, slots)
|
||
* - Visual display of selected schema showing classes, slots, and enums
|
||
* - Raw YAML view toggle
|
||
* - Schema metadata and documentation
|
||
* - URL parameter support for deep linking to specific classes (?class=ClassName)
|
||
*/
|
||
|
||
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||
import { useSearchParams } from 'react-router-dom';
|
||
import ReactMarkdown from 'react-markdown';
|
||
import remarkGfm from 'remark-gfm';
|
||
import rehypeRaw from 'rehype-raw';
|
||
import {
|
||
loadSchema,
|
||
loadSchemaRaw,
|
||
type LinkMLSchema,
|
||
type LinkMLClass,
|
||
type LinkMLSlot,
|
||
type LinkMLEnum,
|
||
type SchemaFile,
|
||
extractClasses,
|
||
extractSlots,
|
||
extractEnums,
|
||
} from '../lib/linkml/schema-loader';
|
||
import { useLanguage } from '../contexts/LanguageContext';
|
||
import { useCollapsibleHeader } from '../hooks/useCollapsibleHeader';
|
||
import { ChevronUp, ChevronDown } from 'lucide-react';
|
||
import './LinkMLViewerPage.css';
|
||
import '../styles/collapsible.css';
|
||
|
||
/**
|
||
* Converts snake_case or kebab-case identifiers to human-readable Title Case.
|
||
*
|
||
* Examples:
|
||
* - "custodian_appellation_class" → "Custodian Appellation Class"
|
||
* - "feature_place_class" → "Feature Place Class"
|
||
* - "heritage-custodian-observation" → "Heritage Custodian Observation"
|
||
*/
|
||
const formatDisplayName = (name: string): string => {
|
||
if (!name) return name;
|
||
|
||
return name
|
||
// Replace underscores and hyphens with spaces
|
||
.replace(/[_-]/g, ' ')
|
||
// Capitalize first letter of each word
|
||
.replace(/\b\w/g, char => char.toUpperCase());
|
||
};
|
||
|
||
/**
|
||
* Admonition transformer for markdown content.
|
||
* Converts patterns like "CRITICAL: message" into styled admonition elements.
|
||
*
|
||
* Supported patterns:
|
||
* - CRITICAL: Urgent, must-know information (red)
|
||
* - WARNING: Potential issues or gotchas (orange)
|
||
* - IMPORTANT: Significant but not urgent (amber)
|
||
* - NOTE: Informational, helpful context (blue)
|
||
*
|
||
* The pattern captures everything from the keyword until:
|
||
* - End of line (for single-line admonitions)
|
||
* - A blank line (paragraph break)
|
||
*/
|
||
const transformAdmonitions = (text: string): string => {
|
||
if (!text) return text;
|
||
|
||
// Pattern matches: KEYWORD: rest of the text until end of paragraph
|
||
// [^\n]* captures everything on the current line
|
||
// (?:\n(?!\n)[^\n]*)* captures continuation lines (lines that don't start a new paragraph)
|
||
const admonitionPattern = /\b(CRITICAL|WARNING|IMPORTANT|NOTE):\s*([^\n]*(?:\n(?!\n)[^\n]*)*)/g;
|
||
|
||
return text.replace(admonitionPattern, (_match, keyword, content) => {
|
||
const type = keyword.toLowerCase();
|
||
// Return HTML that ReactMarkdown will pass through
|
||
return `<span class="linkml-admonition linkml-admonition--${type}"><span class="linkml-admonition__label">${keyword}:</span><span class="linkml-admonition__content">${content.trim()}</span></span>`;
|
||
});
|
||
};
|
||
|
||
/**
|
||
* CURIE (Compact URI) highlighter for markdown content.
|
||
* Highlights patterns like "schema:name", "crm:E41_Appellation", "skos:altLabel".
|
||
*
|
||
* CURIE format: prefix:localName
|
||
* - prefix: lowercase letters (e.g., schema, crm, skos, foaf, dcterms)
|
||
* - localName: letters, numbers, underscores, hyphens (e.g., name, E41_Appellation)
|
||
*
|
||
* Also handles compound CURIEs like "schema:dateCreated/dateModified"
|
||
*/
|
||
const highlightCuries = (text: string): string => {
|
||
if (!text) return text;
|
||
|
||
// Common ontology prefixes used in heritage/linked data
|
||
const knownPrefixes = [
|
||
'schema', 'crm', 'skos', 'foaf', 'dcterms', 'dct', 'dc', 'rdfs', 'rdf', 'owl',
|
||
'prov', 'org', 'locn', 'cpov', 'tooi', 'rico', 'bf', 'wikidata', 'wd', 'wdt',
|
||
'xsd', 'linkml', 'hc', 'pico', 'cv'
|
||
].join('|');
|
||
|
||
// Pattern matches: prefix:localName (with optional /additional parts)
|
||
// Negative lookbehind (?<![:/]) prevents matching URLs like http://...
|
||
// Negative lookahead (?![:/]) prevents partial URL matches
|
||
const curiePattern = new RegExp(
|
||
`(?<![:/])\\b((?:${knownPrefixes}):(?:[A-Za-z][A-Za-z0-9_-]*(?:/[A-Za-z][A-Za-z0-9_-]*)*))(?![:/])`,
|
||
'g'
|
||
);
|
||
|
||
return text.replace(curiePattern, '<code class="linkml-curie">$1</code>');
|
||
};
|
||
|
||
/**
|
||
* Combined content transformer that applies all text transformations.
|
||
* Order matters: CURIEs first (so they don't get broken by admonition spans),
|
||
* then admonitions.
|
||
*/
|
||
const transformContent = (text: string): string => {
|
||
if (!text) return text;
|
||
return transformAdmonitions(highlightCuries(text));
|
||
};
|
||
|
||
// Bilingual text content
|
||
const TEXT = {
|
||
sidebarTitle: { nl: 'LinkML-schema\'s', en: 'LinkML Schemas' },
|
||
pageTitle: { nl: 'LinkML-schemaviewer', en: 'LinkML Schema Viewer' },
|
||
visualView: { nl: 'Visuele weergave', en: 'Visual View' },
|
||
rawYaml: { nl: 'Ruwe YAML', en: 'Raw YAML' },
|
||
loading: { nl: 'Schema laden...', en: 'Loading schema...' },
|
||
noSchemasFound: { nl: 'Geen schema\'s gevonden. Zorg dat het manifest is gegenereerd.', en: 'No schemas found. Make sure the manifest is generated.' },
|
||
failedToInit: { nl: 'Initialiseren van schemalijst mislukt', en: 'Failed to initialize schema list' },
|
||
failedToLoad: { nl: 'Schema laden mislukt:', en: 'Failed to load schema:' },
|
||
unnamedSchema: { nl: 'Naamloos schema', en: 'Unnamed Schema' },
|
||
prefixes: { nl: 'Prefixen', en: 'Prefixes' },
|
||
classes: { nl: 'Klassen', en: 'Classes' },
|
||
slots: { nl: 'Slots', en: 'Slots' },
|
||
enumerations: { nl: 'Enumeraties', en: 'Enumerations' },
|
||
imports: { nl: 'Imports', en: 'Imports' },
|
||
abstract: { nl: 'abstract', en: 'abstract' },
|
||
required: { nl: 'verplicht', en: 'required' },
|
||
multivalued: { nl: 'meervoudig', en: 'multivalued' },
|
||
uri: { nl: 'URI:', en: 'URI:' },
|
||
id: { nl: 'ID:', en: 'ID:' },
|
||
version: { nl: 'Versie:', en: 'Version:' },
|
||
range: { nl: 'Bereik:', en: 'Range:' },
|
||
pattern: { nl: 'Patroon:', en: 'Pattern:' },
|
||
slotsLabel: { nl: 'Slots:', en: 'Slots:' },
|
||
exactMappings: { nl: 'Exacte mappings:', en: 'Exact Mappings:' },
|
||
closeMappings: { nl: 'Vergelijkbare mappings:', en: 'Close Mappings:' },
|
||
permissibleValues: { nl: 'Toegestane waarden:', en: 'Permissible Values:' },
|
||
meaning: { nl: 'betekenis:', en: 'meaning:' },
|
||
searchPlaceholder: { nl: 'Zoeken in waarden...', en: 'Search values...' },
|
||
showAll: { nl: 'Alles tonen', en: 'Show all' },
|
||
showLess: { nl: 'Minder tonen', en: 'Show less' },
|
||
showing: { nl: 'Toont', en: 'Showing' },
|
||
of: { nl: 'van', en: 'of' },
|
||
noResults: { nl: 'Geen resultaten gevonden', en: 'No results found' },
|
||
searchSchemas: { nl: 'Zoeken in schema\'s...', en: 'Search schemas...' },
|
||
mainSchema: { nl: 'Hoofdschema', en: 'Main Schema' },
|
||
noMatchingSchemas: { nl: 'Geen overeenkomende schema\'s', en: 'No matching schemas' },
|
||
copyToClipboard: { nl: 'Kopieer naar klembord', en: 'Copy to clipboard' },
|
||
copied: { nl: 'Gekopieerd!', en: 'Copied!' },
|
||
};
|
||
|
||
// Dynamically discover schema files from the modules directory
|
||
interface SchemaCategory {
|
||
name: string;
|
||
displayName: string;
|
||
files: SchemaFile[];
|
||
}
|
||
|
||
const LinkMLViewerPage: React.FC = () => {
|
||
const { language } = useLanguage();
|
||
const t = (key: keyof typeof TEXT) => TEXT[key][language];
|
||
|
||
const [searchParams] = useSearchParams();
|
||
const [categories, setCategories] = useState<SchemaCategory[]>([]);
|
||
const [selectedSchema, setSelectedSchema] = useState<SchemaFile | null>(null);
|
||
const [schema, setSchema] = useState<LinkMLSchema | null>(null);
|
||
const [rawYaml, setRawYaml] = useState<string | null>(null);
|
||
const [viewMode, setViewMode] = useState<'visual' | 'raw'>('visual');
|
||
const [isLoading, setIsLoading] = useState(true);
|
||
const [error, setError] = useState<string | null>(null);
|
||
const [expandedSections, setExpandedSections] = useState<Set<string>>(new Set(['classes', 'enums', 'slots']));
|
||
const [highlightedClass, setHighlightedClass] = useState<string | null>(null);
|
||
const highlightedRef = useRef<HTMLDivElement>(null);
|
||
|
||
// State for expandable enum ranges in slots
|
||
const [expandedEnumRanges, setExpandedEnumRanges] = useState<Set<string>>(new Set());
|
||
const [loadedEnums, setLoadedEnums] = useState<Record<string, LinkMLEnum | null>>({});
|
||
const [enumSearchFilters, setEnumSearchFilters] = useState<Record<string, string>>({});
|
||
const [enumShowAll, setEnumShowAll] = useState<Record<string, boolean>>({});
|
||
|
||
// State for sidebar search and category filters
|
||
const [sidebarSearch, setSidebarSearch] = useState<string>('');
|
||
const [categoryFilters, setCategoryFilters] = useState<Record<string, boolean>>({
|
||
main: true,
|
||
class: true,
|
||
enum: true,
|
||
slot: true,
|
||
});
|
||
|
||
// State for copy to clipboard feedback
|
||
const [copyFeedback, setCopyFeedback] = useState(false);
|
||
|
||
// Collapsible header
|
||
const mainContentRef = useRef<HTMLElement>(null);
|
||
const { isCollapsed: isHeaderCollapsed, setIsCollapsed: setIsHeaderCollapsed } = useCollapsibleHeader(mainContentRef);
|
||
|
||
// Handle URL parameters for deep linking
|
||
const handleUrlParams = useCallback((cats: SchemaCategory[]) => {
|
||
const classParam = searchParams.get('class');
|
||
|
||
if (classParam) {
|
||
setHighlightedClass(classParam);
|
||
|
||
// Find the schema file that contains this class
|
||
const classFile = cats
|
||
.find(cat => cat.name === 'class')
|
||
?.files.find(file => file.name === classParam);
|
||
|
||
if (classFile) {
|
||
setSelectedSchema(classFile);
|
||
// Ensure classes section is expanded
|
||
setExpandedSections(prev => new Set([...prev, 'classes']));
|
||
}
|
||
}
|
||
}, [searchParams]);
|
||
|
||
// Initialize schema file list from manifest
|
||
useEffect(() => {
|
||
const initializeSchemas = async () => {
|
||
setIsLoading(true);
|
||
try {
|
||
// Fetch the schema manifest (dynamically generated at build time)
|
||
const cats = await fetchSchemaManifest();
|
||
|
||
if (cats.length === 0) {
|
||
setError(t('noSchemasFound'));
|
||
return;
|
||
}
|
||
|
||
setCategories(cats);
|
||
|
||
// Check URL params first for deep linking
|
||
handleUrlParams(cats);
|
||
|
||
// Select main schema by default if no URL param set the schema
|
||
const classParam = searchParams.get('class');
|
||
if (!classParam && cats[0]?.files.length > 0) {
|
||
setSelectedSchema(cats[0].files[0]);
|
||
}
|
||
} catch (err) {
|
||
setError(t('failedToInit'));
|
||
console.error(err);
|
||
} finally {
|
||
setIsLoading(false);
|
||
}
|
||
};
|
||
|
||
initializeSchemas();
|
||
}, [handleUrlParams, searchParams]);
|
||
|
||
// Scroll to highlighted class when it changes
|
||
useEffect(() => {
|
||
if (highlightedClass && highlightedRef.current) {
|
||
setTimeout(() => {
|
||
highlightedRef.current?.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||
}, 100);
|
||
}
|
||
}, [highlightedClass, schema]);
|
||
|
||
// Fetch schema manifest from dynamically generated JSON file
|
||
const fetchSchemaManifest = async (): Promise<SchemaCategory[]> => {
|
||
try {
|
||
const response = await fetch('/schemas/20251121/linkml/manifest.json');
|
||
if (!response.ok) {
|
||
throw new Error(`Failed to fetch manifest: ${response.status}`);
|
||
}
|
||
const manifest = await response.json();
|
||
|
||
// Transform manifest categories to our format
|
||
return manifest.categories.map((cat: { name: string; displayName: string; files: Array<{ name: string; path: string; category: string }> }) => ({
|
||
name: cat.name,
|
||
displayName: cat.displayName,
|
||
files: cat.files.map((file: { name: string; path: string; category: string }) => ({
|
||
name: file.name,
|
||
path: file.path,
|
||
category: cat.name as SchemaFile['category']
|
||
}))
|
||
}));
|
||
} catch (err) {
|
||
console.error('Failed to fetch schema manifest:', err);
|
||
// Return empty categories on error
|
||
return [];
|
||
}
|
||
};
|
||
|
||
// Load selected schema
|
||
useEffect(() => {
|
||
if (!selectedSchema) return;
|
||
|
||
const loadSelectedSchema = async () => {
|
||
setIsLoading(true);
|
||
setError(null);
|
||
|
||
try {
|
||
const [schemaData, yamlContent] = await Promise.all([
|
||
loadSchema(selectedSchema.path),
|
||
loadSchemaRaw(selectedSchema.path)
|
||
]);
|
||
|
||
setSchema(schemaData);
|
||
setRawYaml(yamlContent);
|
||
} catch (err) {
|
||
setError(`${t('failedToLoad')} ${selectedSchema.name}`);
|
||
console.error(err);
|
||
} finally {
|
||
setIsLoading(false);
|
||
}
|
||
};
|
||
|
||
loadSelectedSchema();
|
||
}, [selectedSchema]);
|
||
|
||
const toggleSection = (section: string) => {
|
||
setExpandedSections(prev => {
|
||
const next = new Set(prev);
|
||
if (next.has(section)) {
|
||
next.delete(section);
|
||
} else {
|
||
next.add(section);
|
||
}
|
||
return next;
|
||
});
|
||
};
|
||
|
||
// Check if a range is an enum type
|
||
const isEnumRange = (range: string): boolean => {
|
||
return range.endsWith('Enum');
|
||
};
|
||
|
||
// Toggle enum range expansion and load enum data if needed
|
||
const toggleEnumRange = async (slotName: string, enumName: string) => {
|
||
const key = `${slotName}:${enumName}`;
|
||
|
||
// Toggle expansion state
|
||
setExpandedEnumRanges(prev => {
|
||
const next = new Set(prev);
|
||
if (next.has(key)) {
|
||
next.delete(key);
|
||
} else {
|
||
next.add(key);
|
||
}
|
||
return next;
|
||
});
|
||
|
||
// Load enum data if not already loaded
|
||
if (!loadedEnums[enumName]) {
|
||
try {
|
||
const enumSchema = await loadSchema(`modules/enums/${enumName}.yaml`);
|
||
if (enumSchema?.enums) {
|
||
const enumDef = Object.values(enumSchema.enums)[0] as LinkMLEnum;
|
||
if (enumDef) {
|
||
setLoadedEnums(prev => ({ ...prev, [enumName]: { ...enumDef, name: enumName } }));
|
||
}
|
||
}
|
||
} catch (err) {
|
||
console.error(`Failed to load enum ${enumName}:`, err);
|
||
setLoadedEnums(prev => ({ ...prev, [enumName]: null }));
|
||
}
|
||
}
|
||
};
|
||
|
||
// Render enum values for a slot range
|
||
const renderEnumValues = (_slotName: string, enumName: string) => {
|
||
const enumDef = loadedEnums[enumName];
|
||
|
||
if (!enumDef?.permissible_values) {
|
||
return <div className="linkml-viewer__enum-loading">{t('loading')}</div>;
|
||
}
|
||
|
||
const allValues = Object.entries(enumDef.permissible_values);
|
||
const searchFilter = enumSearchFilters[enumName] || '';
|
||
const showAll = enumShowAll[enumName] || false;
|
||
const displayCount = 20; // Show first 20 values initially
|
||
|
||
// Filter values based on search
|
||
const filteredValues = searchFilter
|
||
? allValues.filter(([value, details]) => {
|
||
const searchLower = searchFilter.toLowerCase();
|
||
return (
|
||
value.toLowerCase().includes(searchLower) ||
|
||
(details.description?.toLowerCase().includes(searchLower)) ||
|
||
(details.meaning?.toLowerCase().includes(searchLower))
|
||
);
|
||
})
|
||
: allValues;
|
||
|
||
// Determine how many to show
|
||
const valuesToShow = showAll || searchFilter ? filteredValues : filteredValues.slice(0, displayCount);
|
||
|
||
return (
|
||
<div className="linkml-viewer__range-enum-values">
|
||
<div className="linkml-viewer__range-enum-header">
|
||
{/* Search input */}
|
||
<div className="linkml-viewer__range-enum-search">
|
||
<input
|
||
type="text"
|
||
placeholder={t('searchPlaceholder')}
|
||
value={searchFilter}
|
||
onChange={(e) => setEnumSearchFilters(prev => ({ ...prev, [enumName]: e.target.value }))}
|
||
className="linkml-viewer__range-enum-search-input"
|
||
/>
|
||
{searchFilter && (
|
||
<button
|
||
className="linkml-viewer__range-enum-search-clear"
|
||
onClick={() => setEnumSearchFilters(prev => ({ ...prev, [enumName]: '' }))}
|
||
title="Clear search"
|
||
>
|
||
×
|
||
</button>
|
||
)}
|
||
</div>
|
||
|
||
{/* Count display */}
|
||
<span className="linkml-viewer__range-enum-count">
|
||
{searchFilter ? (
|
||
<>
|
||
{t('showing')} {filteredValues.length} {t('of')} {allValues.length}
|
||
</>
|
||
) : showAll ? (
|
||
<>
|
||
{allValues.length} {t('permissibleValues').replace(':', '')}
|
||
</>
|
||
) : (
|
||
<>
|
||
{t('showing')} {Math.min(displayCount, allValues.length)} {t('of')} {allValues.length}
|
||
</>
|
||
)}
|
||
</span>
|
||
</div>
|
||
|
||
<div className="linkml-viewer__range-enum-list">
|
||
{valuesToShow.length > 0 ? (
|
||
valuesToShow.map(([value, details]) => (
|
||
<div key={value} className="linkml-viewer__range-enum-item">
|
||
<code className="linkml-viewer__range-enum-name">{value}</code>
|
||
{details.meaning && (
|
||
<a
|
||
href={details.meaning.startsWith('wikidata:')
|
||
? `https://www.wikidata.org/wiki/${details.meaning.replace('wikidata:', '')}`
|
||
: details.meaning}
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
className="linkml-viewer__range-enum-link"
|
||
title={details.meaning}
|
||
>
|
||
🔗
|
||
</a>
|
||
)}
|
||
{details.description && (
|
||
<span className="linkml-viewer__range-enum-desc">{details.description}</span>
|
||
)}
|
||
</div>
|
||
))
|
||
) : (
|
||
<div className="linkml-viewer__range-enum-no-results">
|
||
{t('noResults')}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Show all / Show less button */}
|
||
{!searchFilter && allValues.length > displayCount && (
|
||
<button
|
||
className="linkml-viewer__range-enum-toggle-all"
|
||
onClick={() => setEnumShowAll(prev => ({ ...prev, [enumName]: !showAll }))}
|
||
>
|
||
{showAll ? (
|
||
<>▲ {t('showLess')}</>
|
||
) : (
|
||
<>▼ {t('showAll')} ({allValues.length - displayCount} {language === 'nl' ? 'meer' : 'more'})</>
|
||
)}
|
||
</button>
|
||
)}
|
||
</div>
|
||
);
|
||
};
|
||
|
||
const renderClassDetails = (cls: LinkMLClass) => {
|
||
const isHighlighted = highlightedClass === cls.name;
|
||
|
||
return (
|
||
<div
|
||
key={cls.name}
|
||
ref={isHighlighted ? highlightedRef : null}
|
||
className={`linkml-viewer__item ${isHighlighted ? 'linkml-viewer__item--highlighted' : ''}`}
|
||
>
|
||
<h4 className="linkml-viewer__item-name">
|
||
{cls.name}
|
||
{cls.abstract && <span className="linkml-viewer__badge linkml-viewer__badge--abstract">{t('abstract')}</span>}
|
||
</h4>
|
||
{cls.class_uri && (
|
||
<div className="linkml-viewer__uri">
|
||
<span className="linkml-viewer__label">{t('uri')}</span>
|
||
<code>{cls.class_uri}</code>
|
||
</div>
|
||
)}
|
||
{cls.description && (
|
||
<div className="linkml-viewer__description linkml-viewer__markdown">
|
||
<ReactMarkdown remarkPlugins={[remarkGfm]} rehypePlugins={[rehypeRaw]}>{transformContent(cls.description)}</ReactMarkdown>
|
||
</div>
|
||
)}
|
||
{cls.slots && cls.slots.length > 0 && (
|
||
<div className="linkml-viewer__slots-list">
|
||
<span className="linkml-viewer__label">{t('slotsLabel')}</span>
|
||
<div className="linkml-viewer__tag-list">
|
||
{cls.slots.map(slot => (
|
||
<span key={slot} className="linkml-viewer__tag">{slot}</span>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
{cls.exact_mappings && cls.exact_mappings.length > 0 && (
|
||
<div className="linkml-viewer__mappings">
|
||
<span className="linkml-viewer__label">{t('exactMappings')}</span>
|
||
<div className="linkml-viewer__tag-list">
|
||
{cls.exact_mappings.map(mapping => (
|
||
<span key={mapping} className="linkml-viewer__tag linkml-viewer__tag--mapping">{mapping}</span>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
{cls.close_mappings && cls.close_mappings.length > 0 && (
|
||
<div className="linkml-viewer__mappings">
|
||
<span className="linkml-viewer__label">{t('closeMappings')}</span>
|
||
<div className="linkml-viewer__tag-list">
|
||
{cls.close_mappings.map(mapping => (
|
||
<span key={mapping} className="linkml-viewer__tag linkml-viewer__tag--close">{mapping}</span>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
};
|
||
|
||
const renderSlotDetails = (slot: LinkMLSlot) => {
|
||
const rangeIsEnum = slot.range && isEnumRange(slot.range);
|
||
const enumKey = slot.range ? `${slot.name}:${slot.range}` : '';
|
||
const isExpanded = expandedEnumRanges.has(enumKey);
|
||
|
||
return (
|
||
<div key={slot.name} className="linkml-viewer__item">
|
||
<h4 className="linkml-viewer__item-name">
|
||
{slot.name}
|
||
{slot.required && <span className="linkml-viewer__badge linkml-viewer__badge--required">{t('required')}</span>}
|
||
{slot.multivalued && <span className="linkml-viewer__badge linkml-viewer__badge--multi">{t('multivalued')}</span>}
|
||
</h4>
|
||
{slot.slot_uri && (
|
||
<div className="linkml-viewer__uri">
|
||
<span className="linkml-viewer__label">{t('uri')}</span>
|
||
<code>{slot.slot_uri}</code>
|
||
</div>
|
||
)}
|
||
{slot.range && (
|
||
<div className="linkml-viewer__range">
|
||
<span className="linkml-viewer__label">{t('range')}</span>
|
||
{rangeIsEnum ? (
|
||
<button
|
||
className={`linkml-viewer__range-enum-toggle ${isExpanded ? 'linkml-viewer__range-enum-toggle--expanded' : ''}`}
|
||
onClick={() => toggleEnumRange(slot.name, slot.range!)}
|
||
>
|
||
<span className="linkml-viewer__range-enum-icon">{isExpanded ? '▼' : '▶'}</span>
|
||
<code>{slot.range}</code>
|
||
<span className="linkml-viewer__badge linkml-viewer__badge--enum">enum</span>
|
||
</button>
|
||
) : (
|
||
<code>{slot.range}</code>
|
||
)}
|
||
</div>
|
||
)}
|
||
{/* Expandable enum values */}
|
||
{rangeIsEnum && isExpanded && slot.range && renderEnumValues(slot.name, slot.range)}
|
||
{slot.description && (
|
||
<div className="linkml-viewer__description linkml-viewer__markdown">
|
||
<ReactMarkdown remarkPlugins={[remarkGfm]} rehypePlugins={[rehypeRaw]}>{transformContent(slot.description)}</ReactMarkdown>
|
||
</div>
|
||
)}
|
||
{slot.pattern && (
|
||
<div className="linkml-viewer__pattern">
|
||
<span className="linkml-viewer__label">{t('pattern')}</span>
|
||
<code>{slot.pattern}</code>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
};
|
||
|
||
const renderEnumDetails = (enumDef: LinkMLEnum) => {
|
||
const enumName = enumDef.name;
|
||
const allValues = enumDef.permissible_values ? Object.entries(enumDef.permissible_values) : [];
|
||
const searchFilter = enumSearchFilters[enumName] || '';
|
||
const showAll = enumShowAll[enumName] || false;
|
||
const displayCount = 20;
|
||
|
||
// Filter values based on search
|
||
const filteredValues = searchFilter
|
||
? allValues.filter(([value, details]) => {
|
||
const searchLower = searchFilter.toLowerCase();
|
||
return (
|
||
value.toLowerCase().includes(searchLower) ||
|
||
(details.description?.toLowerCase().includes(searchLower)) ||
|
||
(details.meaning?.toLowerCase().includes(searchLower))
|
||
);
|
||
})
|
||
: allValues;
|
||
|
||
// Determine how many to show
|
||
const valuesToShow = showAll || searchFilter ? filteredValues : filteredValues.slice(0, displayCount);
|
||
|
||
return (
|
||
<div key={enumDef.name} className="linkml-viewer__item">
|
||
<h4 className="linkml-viewer__item-name">{enumDef.name}</h4>
|
||
{enumDef.description && (
|
||
<div className="linkml-viewer__description linkml-viewer__markdown">
|
||
<ReactMarkdown remarkPlugins={[remarkGfm]} rehypePlugins={[rehypeRaw]}>{transformContent(enumDef.description)}</ReactMarkdown>
|
||
</div>
|
||
)}
|
||
{allValues.length > 0 && (
|
||
<div className="linkml-viewer__enum-values">
|
||
{/* Search input */}
|
||
<div className="linkml-viewer__range-enum-search">
|
||
<input
|
||
type="text"
|
||
placeholder={t('searchPlaceholder')}
|
||
value={searchFilter}
|
||
onChange={(e) => setEnumSearchFilters(prev => ({ ...prev, [enumName]: e.target.value }))}
|
||
className="linkml-viewer__range-enum-search-input"
|
||
/>
|
||
{searchFilter && (
|
||
<button
|
||
className="linkml-viewer__range-enum-search-clear"
|
||
onClick={() => setEnumSearchFilters(prev => ({ ...prev, [enumName]: '' }))}
|
||
title="Clear search"
|
||
>
|
||
×
|
||
</button>
|
||
)}
|
||
</div>
|
||
|
||
{/* Count display */}
|
||
<span className="linkml-viewer__range-enum-count" style={{ display: 'block', marginBottom: '0.5rem' }}>
|
||
{searchFilter ? (
|
||
<>
|
||
{t('showing')} {filteredValues.length} {t('of')} {allValues.length}
|
||
</>
|
||
) : showAll ? (
|
||
<>
|
||
{allValues.length} {t('permissibleValues').replace(':', '')}
|
||
</>
|
||
) : (
|
||
<>
|
||
{t('showing')} {Math.min(displayCount, allValues.length)} {t('of')} {allValues.length}
|
||
</>
|
||
)}
|
||
</span>
|
||
|
||
<div className="linkml-viewer__value-list">
|
||
{valuesToShow.length > 0 ? (
|
||
valuesToShow.map(([value, details]) => (
|
||
<div key={value} className="linkml-viewer__value-item">
|
||
<code className="linkml-viewer__value-name">{value}</code>
|
||
{details.description && (
|
||
<div className="linkml-viewer__value-desc linkml-viewer__markdown linkml-viewer__markdown--compact">
|
||
<ReactMarkdown remarkPlugins={[remarkGfm]} rehypePlugins={[rehypeRaw]}>{transformContent(details.description)}</ReactMarkdown>
|
||
</div>
|
||
)}
|
||
{details.meaning && (
|
||
<span className="linkml-viewer__value-meaning">
|
||
<span className="linkml-viewer__label">{t('meaning')}</span> {details.meaning}
|
||
</span>
|
||
)}
|
||
</div>
|
||
))
|
||
) : (
|
||
<div className="linkml-viewer__range-enum-no-results">
|
||
{t('noResults')}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Show all / Show less button */}
|
||
{!searchFilter && allValues.length > displayCount && (
|
||
<button
|
||
className="linkml-viewer__range-enum-toggle-all"
|
||
onClick={() => setEnumShowAll(prev => ({ ...prev, [enumName]: !showAll }))}
|
||
>
|
||
{showAll ? (
|
||
<>▲ {t('showLess')}</>
|
||
) : (
|
||
<>▼ {t('showAll')} ({allValues.length - displayCount} {language === 'nl' ? 'meer' : 'more'})</>
|
||
)}
|
||
</button>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
};
|
||
|
||
const renderVisualView = () => {
|
||
if (!schema) return null;
|
||
|
||
const classes = extractClasses(schema);
|
||
const slots = extractSlots(schema);
|
||
const enums = extractEnums(schema);
|
||
|
||
return (
|
||
<div className="linkml-viewer__visual">
|
||
{/* Schema Metadata */}
|
||
<div className="linkml-viewer__metadata">
|
||
<h2 className="linkml-viewer__schema-name">{formatDisplayName(schema.name || schema.title || '') || t('unnamedSchema')}</h2>
|
||
{schema.description && (
|
||
<div className="linkml-viewer__schema-desc linkml-viewer__markdown">
|
||
<ReactMarkdown remarkPlugins={[remarkGfm]} rehypePlugins={[rehypeRaw]}>{transformContent(schema.description)}</ReactMarkdown>
|
||
</div>
|
||
)}
|
||
{schema.id && (
|
||
<div className="linkml-viewer__schema-id">
|
||
<span className="linkml-viewer__label">{t('id')}</span>
|
||
<code>{schema.id}</code>
|
||
</div>
|
||
)}
|
||
{schema.version && (
|
||
<div className="linkml-viewer__schema-version">
|
||
<span className="linkml-viewer__label">{t('version')}</span>
|
||
<code>{schema.version}</code>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Prefixes */}
|
||
{schema.prefixes && Object.keys(schema.prefixes).length > 0 && (
|
||
<div className="linkml-viewer__section">
|
||
<button
|
||
className="linkml-viewer__section-header"
|
||
onClick={() => toggleSection('prefixes')}
|
||
>
|
||
<span className="linkml-viewer__section-icon">
|
||
{expandedSections.has('prefixes') ? '▼' : '▶'}
|
||
</span>
|
||
{t('prefixes')} ({Object.keys(schema.prefixes).length})
|
||
</button>
|
||
{expandedSections.has('prefixes') && (
|
||
<div className="linkml-viewer__prefix-list">
|
||
{Object.entries(schema.prefixes).map(([prefix, uri]) => (
|
||
<div key={prefix} className="linkml-viewer__prefix-item">
|
||
<code className="linkml-viewer__prefix-name">{prefix}:</code>
|
||
<span className="linkml-viewer__prefix-uri">{uri}</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* Classes */}
|
||
{classes.length > 0 && (
|
||
<div className="linkml-viewer__section">
|
||
<button
|
||
className="linkml-viewer__section-header"
|
||
onClick={() => toggleSection('classes')}
|
||
>
|
||
<span className="linkml-viewer__section-icon">
|
||
{expandedSections.has('classes') ? '▼' : '▶'}
|
||
</span>
|
||
{t('classes')} ({classes.length})
|
||
</button>
|
||
{expandedSections.has('classes') && (
|
||
<div className="linkml-viewer__section-content">
|
||
{classes.map(renderClassDetails)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* Slots */}
|
||
{slots.length > 0 && (
|
||
<div className="linkml-viewer__section">
|
||
<button
|
||
className="linkml-viewer__section-header"
|
||
onClick={() => toggleSection('slots')}
|
||
>
|
||
<span className="linkml-viewer__section-icon">
|
||
{expandedSections.has('slots') ? '▼' : '▶'}
|
||
</span>
|
||
{t('slots')} ({slots.length})
|
||
</button>
|
||
{expandedSections.has('slots') && (
|
||
<div className="linkml-viewer__section-content">
|
||
{slots.map(renderSlotDetails)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* Enums */}
|
||
{enums.length > 0 && (
|
||
<div className="linkml-viewer__section">
|
||
<button
|
||
className="linkml-viewer__section-header"
|
||
onClick={() => toggleSection('enums')}
|
||
>
|
||
<span className="linkml-viewer__section-icon">
|
||
{expandedSections.has('enums') ? '▼' : '▶'}
|
||
</span>
|
||
{t('enumerations')} ({enums.length})
|
||
</button>
|
||
{expandedSections.has('enums') && (
|
||
<div className="linkml-viewer__section-content">
|
||
{enums.map(renderEnumDetails)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* Imports */}
|
||
{schema.imports && schema.imports.length > 0 && (
|
||
<div className="linkml-viewer__section">
|
||
<button
|
||
className="linkml-viewer__section-header"
|
||
onClick={() => toggleSection('imports')}
|
||
>
|
||
<span className="linkml-viewer__section-icon">
|
||
{expandedSections.has('imports') ? '▼' : '▶'}
|
||
</span>
|
||
{t('imports')} ({schema.imports.length})
|
||
</button>
|
||
{expandedSections.has('imports') && (
|
||
<div className="linkml-viewer__import-list">
|
||
{schema.imports.map(imp => (
|
||
<div key={imp} className="linkml-viewer__import-item">
|
||
<code>{imp}</code>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
};
|
||
|
||
const renderRawView = () => {
|
||
if (!rawYaml) return null;
|
||
|
||
const handleCopyYaml = async () => {
|
||
try {
|
||
await navigator.clipboard.writeText(rawYaml);
|
||
setCopyFeedback(true);
|
||
setTimeout(() => setCopyFeedback(false), 2000);
|
||
} catch (err) {
|
||
console.error('Failed to copy YAML:', err);
|
||
}
|
||
};
|
||
|
||
return (
|
||
<div className="linkml-viewer__raw">
|
||
<div className="linkml-viewer__raw-header">
|
||
<button
|
||
className={`linkml-viewer__copy-btn ${copyFeedback ? 'linkml-viewer__copy-btn--copied' : ''}`}
|
||
onClick={handleCopyYaml}
|
||
title={copyFeedback ? t('copied') : t('copyToClipboard')}
|
||
>
|
||
{copyFeedback ? '✓' : '⧉'} {copyFeedback ? t('copied') : t('copyToClipboard')}
|
||
</button>
|
||
</div>
|
||
<pre className="linkml-viewer__yaml">{rawYaml}</pre>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
return (
|
||
<div className="linkml-viewer-page">
|
||
{/* Left Sidebar - Schema Files */}
|
||
<aside className="linkml-viewer-page__sidebar">
|
||
<h2 className="linkml-viewer-page__sidebar-title">{t('sidebarTitle')}</h2>
|
||
|
||
{/* Sidebar Search */}
|
||
<div className="linkml-viewer-page__sidebar-search">
|
||
<input
|
||
type="text"
|
||
placeholder={t('searchSchemas')}
|
||
value={sidebarSearch}
|
||
onChange={(e) => setSidebarSearch(e.target.value)}
|
||
className="linkml-viewer-page__sidebar-search-input"
|
||
/>
|
||
{sidebarSearch && (
|
||
<button
|
||
className="linkml-viewer-page__sidebar-search-clear"
|
||
onClick={() => setSidebarSearch('')}
|
||
title="Clear search"
|
||
>
|
||
×
|
||
</button>
|
||
)}
|
||
</div>
|
||
|
||
{/* Category Filters */}
|
||
<div className="linkml-viewer-page__sidebar-filters">
|
||
<label className="linkml-viewer-page__filter-checkbox">
|
||
<input
|
||
type="checkbox"
|
||
checked={categoryFilters.main}
|
||
onChange={(e) => setCategoryFilters(prev => ({ ...prev, main: e.target.checked }))}
|
||
/>
|
||
<span className="linkml-viewer-page__filter-label linkml-viewer-page__filter-label--main">
|
||
{t('mainSchema')}
|
||
</span>
|
||
</label>
|
||
<label className="linkml-viewer-page__filter-checkbox">
|
||
<input
|
||
type="checkbox"
|
||
checked={categoryFilters.class}
|
||
onChange={(e) => setCategoryFilters(prev => ({ ...prev, class: e.target.checked }))}
|
||
/>
|
||
<span className="linkml-viewer-page__filter-label linkml-viewer-page__filter-label--class">
|
||
{t('classes')}
|
||
</span>
|
||
</label>
|
||
<label className="linkml-viewer-page__filter-checkbox">
|
||
<input
|
||
type="checkbox"
|
||
checked={categoryFilters.enum}
|
||
onChange={(e) => setCategoryFilters(prev => ({ ...prev, enum: e.target.checked }))}
|
||
/>
|
||
<span className="linkml-viewer-page__filter-label linkml-viewer-page__filter-label--enum">
|
||
{t('enumerations')}
|
||
</span>
|
||
</label>
|
||
<label className="linkml-viewer-page__filter-checkbox">
|
||
<input
|
||
type="checkbox"
|
||
checked={categoryFilters.slot}
|
||
onChange={(e) => setCategoryFilters(prev => ({ ...prev, slot: e.target.checked }))}
|
||
/>
|
||
<span className="linkml-viewer-page__filter-label linkml-viewer-page__filter-label--slot">
|
||
{t('slots')}
|
||
</span>
|
||
</label>
|
||
</div>
|
||
|
||
{categories
|
||
.filter(category => categoryFilters[category.name] !== false)
|
||
.map(category => {
|
||
// Filter files based on search
|
||
const filteredFiles = sidebarSearch
|
||
? category.files.filter(file =>
|
||
file.name.toLowerCase().includes(sidebarSearch.toLowerCase())
|
||
)
|
||
: category.files;
|
||
|
||
// Don't render empty categories
|
||
if (filteredFiles.length === 0) return null;
|
||
|
||
return (
|
||
<div key={category.name} className="linkml-viewer-page__category">
|
||
<h3 className="linkml-viewer-page__category-title">
|
||
{category.displayName}
|
||
<span className="linkml-viewer-page__category-count">
|
||
({filteredFiles.length}{sidebarSearch ? `/${category.files.length}` : ''})
|
||
</span>
|
||
</h3>
|
||
<div className="linkml-viewer-page__file-list">
|
||
{filteredFiles.map(file => (
|
||
<button
|
||
key={file.path}
|
||
className={`linkml-viewer-page__file-item ${
|
||
selectedSchema?.path === file.path ? 'linkml-viewer-page__file-item--active' : ''
|
||
}`}
|
||
onClick={() => setSelectedSchema(file)}
|
||
>
|
||
{file.name}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
|
||
{/* No results message */}
|
||
{sidebarSearch && categories.every(category =>
|
||
!categoryFilters[category.name] ||
|
||
category.files.filter(file =>
|
||
file.name.toLowerCase().includes(sidebarSearch.toLowerCase())
|
||
).length === 0
|
||
) && (
|
||
<div className="linkml-viewer-page__no-results">
|
||
{t('noMatchingSchemas')}
|
||
</div>
|
||
)}
|
||
|
||
<div className="minimal-footer">
|
||
© 2025{' '}
|
||
<a href="https://www.netwerkdigitaalerfgoed.nl" target="_blank" rel="noopener noreferrer">
|
||
Netwerk Digitaal Erfgoed
|
||
</a>
|
||
{' & '}
|
||
<a href="https://www.textpast.com" target="_blank" rel="noopener noreferrer">
|
||
TextPast
|
||
</a>
|
||
.{' '}
|
||
{language === 'nl' ? 'Alle rechten voorbehouden.' : 'All rights reserved.'}
|
||
</div>
|
||
</aside>
|
||
|
||
{/* Main Content */}
|
||
<main className="linkml-viewer-page__main" ref={mainContentRef}>
|
||
<header className={`linkml-viewer-page__header collapsible-header ${isHeaderCollapsed ? 'collapsible-header--collapsed' : ''}`}>
|
||
<h1 className="linkml-viewer-page__title">
|
||
{selectedSchema ? formatDisplayName(selectedSchema.name) : t('pageTitle')}
|
||
</h1>
|
||
<div className="linkml-viewer-page__tabs">
|
||
<button
|
||
className={`linkml-viewer-page__tab ${viewMode === 'visual' ? 'linkml-viewer-page__tab--active' : ''}`}
|
||
onClick={() => setViewMode('visual')}
|
||
>
|
||
{t('visualView')}
|
||
</button>
|
||
<button
|
||
className={`linkml-viewer-page__tab ${viewMode === 'raw' ? 'linkml-viewer-page__tab--active' : ''}`}
|
||
onClick={() => setViewMode('raw')}
|
||
>
|
||
{t('rawYaml')}
|
||
</button>
|
||
</div>
|
||
</header>
|
||
|
||
{/* Header Toggle Bar */}
|
||
<div className="header-toggle-bar">
|
||
<button
|
||
className="header-toggle-btn"
|
||
onClick={() => setIsHeaderCollapsed(!isHeaderCollapsed)}
|
||
title={isHeaderCollapsed ? (language === 'nl' ? 'Toon header' : 'Show header') : (language === 'nl' ? 'Verberg header' : 'Hide header')}
|
||
>
|
||
{isHeaderCollapsed ? <ChevronDown size={16} /> : <ChevronUp size={16} />}
|
||
<span>{isHeaderCollapsed ? (language === 'nl' ? 'Toon header' : 'Show header') : (language === 'nl' ? 'Verberg header' : 'Hide header')}</span>
|
||
</button>
|
||
</div>
|
||
|
||
<div className="linkml-viewer-page__content">
|
||
{isLoading ? (
|
||
<div className="linkml-viewer-page__loading">{t('loading')}</div>
|
||
) : error ? (
|
||
<div className="linkml-viewer-page__error">{error}</div>
|
||
) : viewMode === 'visual' ? (
|
||
renderVisualView()
|
||
) : (
|
||
renderRawView()
|
||
)}
|
||
</div>
|
||
</main>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
export default LinkMLViewerPage;
|