feat(api): Add TypeScript components for API UI interactions

- Add api-nav.ts for sidebar navigation collapse/expand
- Add api-scalar.ts for Scalar API renderer integration
- Add api-tabs.ts for tab switching functionality
- Add api-toc.ts for table of contents generation
- Register components in main.js
worktree-2025-12-30T19-16-55
Jason Stirnaman 2025-12-08 14:06:43 -06:00
parent 14e5312cbc
commit 40cf280c5e
5 changed files with 988 additions and 0 deletions

View File

@ -0,0 +1,76 @@
/**
* API Navigation Component
*
* Handles collapsible navigation groups in the API sidebar.
* Features:
* - Toggle expand/collapse on group headers
* - ARIA accessibility support
* - Keyboard navigation
*
* Usage:
* <nav class="api-nav" data-component="api-nav">
* <div class="api-nav-group">
* <button class="api-nav-group-header" aria-expanded="false">
* Group Title
* </button>
* <ul class="api-nav-group-items">
* ...
* </ul>
* </div>
* </nav>
*/
interface ComponentOptions {
component: HTMLElement;
}
/**
* Initialize API Navigation component
*/
export default function ApiNav({ component }: ComponentOptions): void {
const headers = component.querySelectorAll<HTMLButtonElement>(
'.api-nav-group-header'
);
headers.forEach((header) => {
header.addEventListener('click', () => {
const isOpen = header.classList.toggle('is-open');
header.setAttribute('aria-expanded', String(isOpen));
const items = header.nextElementSibling;
if (items) {
items.classList.toggle('is-open', isOpen);
}
});
// Keyboard support - Enter and Space already work for buttons
// but add support for arrow keys to navigate between groups
header.addEventListener('keydown', (event: KeyboardEvent) => {
const allHeaders = Array.from(headers);
const currentIndex = allHeaders.indexOf(header);
switch (event.key) {
case 'ArrowDown':
event.preventDefault();
if (currentIndex < allHeaders.length - 1) {
allHeaders[currentIndex + 1].focus();
}
break;
case 'ArrowUp':
event.preventDefault();
if (currentIndex > 0) {
allHeaders[currentIndex - 1].focus();
}
break;
case 'Home':
event.preventDefault();
allHeaders[0].focus();
break;
case 'End':
event.preventDefault();
allHeaders[allHeaders.length - 1].focus();
break;
}
});
});
}

View File

@ -0,0 +1,326 @@
/**
* Scalar API Documentation Component
*
* Initializes the Scalar API reference viewer for OpenAPI documentation.
* Features:
* - Dynamic CDN loading of Scalar library
* - Theme synchronization with site theme
* - InfluxData brand colors
* - Error handling and fallback UI
*
* Usage:
* <div data-component="api-scalar" data-spec-path="/path/to/spec.yml"></div>
*/
import { getPreference } from '../services/local-storage.js';
interface ComponentOptions {
component: HTMLElement;
}
interface ScalarConfig {
url: string;
forceDarkModeState?: 'dark' | 'light';
layout?: 'classic' | 'modern';
showSidebar?: boolean;
hideDarkModeToggle?: boolean;
hideSearch?: boolean;
documentDownloadType?: 'none' | 'yaml' | 'json';
hideModels?: boolean;
hideTestRequestButton?: boolean;
withDefaultFonts?: boolean;
customCss?: string;
}
type ScalarCreateFn = (
selector: string | HTMLElement,
config: ScalarConfig
) => void;
declare global {
interface Window {
Scalar?: {
createApiReference: ScalarCreateFn;
};
}
}
const SCALAR_CDN = 'https://cdn.jsdelivr.net/npm/@scalar/api-reference@latest';
/**
* Load script dynamically
*/
function loadScript(src: string, timeout = 8000): Promise<void> {
return new Promise((resolve, reject) => {
// Check if script already exists
const existing = Array.from(document.scripts).find(
(s) => s.src && s.src.includes(src)
);
if (existing && window.Scalar?.createApiReference) {
return resolve();
}
const script = document.createElement('script');
script.src = src;
script.defer = true;
script.onload = () => resolve();
script.onerror = () => reject(new Error(`Failed to load script: ${src}`));
document.head.appendChild(script);
// Fallback timeout
setTimeout(() => {
if (window.Scalar?.createApiReference) {
resolve();
} else {
reject(new Error(`Timeout loading script: ${src}`));
}
}, timeout);
});
}
/**
* Get current theme from localStorage (source of truth for Hugo theme system)
*/
function getTheme(): 'dark' | 'light' {
const theme = getPreference('theme');
return theme === 'dark' ? 'dark' : 'light';
}
/**
* Poll for Scalar availability
*/
function waitForScalar(maxAttempts = 50, interval = 100): Promise<void> {
return new Promise((resolve, reject) => {
let attempts = 0;
const checkInterval = setInterval(() => {
attempts++;
if (window.Scalar?.createApiReference) {
clearInterval(checkInterval);
resolve();
} else if (attempts >= maxAttempts) {
clearInterval(checkInterval);
reject(
new Error(`Scalar not available after ${maxAttempts * interval}ms`)
);
}
}, interval);
});
}
/**
* Initialize Scalar API reference
*/
async function initScalar(
container: HTMLElement,
specUrl: string
): Promise<void> {
if (!window.Scalar?.createApiReference) {
throw new Error('Scalar is not available');
}
// Clean up previous Scalar instance (important for theme switching)
// Remove any Scalar-injected content and classes
container.innerHTML = '';
// Remove Scalar's dark-mode class from body if it exists
document.body.classList.remove('dark-mode');
const isDark = getTheme() === 'dark';
window.Scalar.createApiReference(container, {
url: specUrl,
forceDarkModeState: getTheme(),
layout: 'classic',
showSidebar: false,
hideDarkModeToggle: true,
hideSearch: true,
documentDownloadType: 'none',
hideModels: false,
hideTestRequestButton: false,
withDefaultFonts: false,
customCss: `
:root {
/* Typography - match Hugo docs site */
--scalar-font: 'Proxima Nova', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
--scalar-font-code: 'IBM Plex Mono', Monaco, Consolas, monospace;
--scalar-font-size-base: 16px;
--scalar-line-height: 1.65;
/* InfluxData brand colors */
--scalar-color-1: #F63C41;
--scalar-color-2: #d32f34;
--scalar-color-accent: #F63C41;
/* Border radius */
--scalar-radius: 4px;
--scalar-radius-lg: 8px;
/* Background and text colors - theme-aware */
--scalar-background-1: ${isDark ? '#1a1a2e' : '#ffffff'};
--scalar-background-2: ${isDark ? '#232338' : '#f7f8fa'};
--scalar-background-3: ${isDark ? '#2d2d44' : '#f0f2f5'};
--scalar-text-1: ${isDark ? '#e0e0e0' : '#2b2b2b'};
--scalar-text-2: ${isDark ? '#a0a0a0' : '#545454'};
--scalar-text-3: ${isDark ? '#888888' : '#757575'};
--scalar-border-color: ${isDark ? '#3a3a50' : '#e0e0e0'};
/* Heading colors */
--scalar-heading-color: ${isDark ? '#ffffff' : '#2b2b2b'};
}
/* Match Hugo heading styles */
h1, h2, h3, h4, h5, h6 {
font-family: var(--scalar-font);
font-weight: 600;
color: var(--scalar-heading-color);
line-height: 1.25;
}
h1 { font-size: 2rem; }
h2 { font-size: 1.5rem; margin-top: 2rem; }
h3 { font-size: 1.25rem; margin-top: 1.5rem; }
h4 { font-size: 1rem; margin-top: 1rem; }
/* Body text size */
p, li, td, th {
font-size: 1rem;
line-height: var(--scalar-line-height);
}
/* Code block styling */
pre, code {
font-family: var(--scalar-font-code);
font-size: 0.875rem;
}
/* Hide section-content div */
div.section-content {
display: none !important;
}
`,
});
console.log(
'[API Docs] Scalar initialized with spec:',
specUrl,
'theme:',
getTheme()
);
}
/**
* Show error message in container
*/
function showError(container: HTMLElement, message: string): void {
container.innerHTML = `<p class="error">${message}</p>`;
}
/**
* Watch for Hugo theme changes via stylesheet manipulation
* Hugo theme.js enables/disables link[title*="theme"] elements
*/
function watchThemeChanges(container: HTMLElement, specUrl: string): void {
// Watch for stylesheet changes in the document
const observer = new MutationObserver(() => {
const currentTheme = getTheme();
console.log('[API Docs] Theme changed to:', currentTheme);
// Re-initialize Scalar with new theme
initScalar(container, specUrl).catch((error) => {
console.error(
'[API Docs] Failed to re-initialize Scalar on theme change:',
error
);
});
});
// Watch for changes to stylesheet link elements
const head = document.querySelector('head');
if (head) {
observer.observe(head, {
attributes: true,
attributeFilter: ['disabled'],
subtree: true,
});
}
// Also watch for localStorage changes from other tabs
window.addEventListener('storage', (event) => {
if (event.key === 'influxdata_docs_preferences' && event.newValue) {
try {
const prefs = JSON.parse(event.newValue);
if (prefs.theme) {
const currentTheme = getTheme();
console.log(
'[API Docs] Theme changed via storage event to:',
currentTheme
);
initScalar(container, specUrl).catch((error) => {
console.error(
'[API Docs] Failed to re-initialize Scalar on storage change:',
error
);
});
}
} catch (error) {
console.error(
'[API Docs] Failed to parse localStorage preferences:',
error
);
}
}
});
}
/**
* Initialize API Scalar component
*/
export default async function ApiScalar({
component,
}: ComponentOptions): Promise<void> {
try {
// Get spec path from data attribute
const specPath = component.dataset.specPath;
const cdn = component.dataset.cdn || SCALAR_CDN;
if (!specPath) {
console.error('[API Docs] No OpenAPI specification path provided');
showError(
component,
'Error: No API specification configured for this page.'
);
return;
}
// Build full URL for spec (Scalar needs absolute URL)
const specUrl = window.location.origin + specPath;
// Load Scalar from CDN if not already loaded
if (!window.Scalar?.createApiReference) {
try {
await loadScript(cdn);
} catch (err) {
console.error('[API Docs] Failed to load Scalar from CDN', err);
}
}
// Wait for Scalar to be ready
try {
await waitForScalar();
} catch (err) {
console.error('[API Docs] Scalar failed to initialize', err);
showError(component, 'Error: API viewer failed to load.');
return;
}
// Initialize Scalar
await initScalar(component, specUrl);
// Watch for theme changes and re-initialize Scalar when theme changes
watchThemeChanges(component, specUrl);
} catch (err) {
console.error('[API Docs] ApiScalar component error', err);
showError(component, 'Error: API viewer failed to initialize.');
}
}

View File

@ -0,0 +1,144 @@
/**
* API Tabs Component
*
* Handles tab switching for API reference documentation.
* Uses data-tab and data-tab-panel attributes for explicit panel targeting,
* unlike the generic tabs which use positional indexing.
*
* Features:
* - Explicit panel targeting via data-tab-panel
* - Deep linking via URL hash
* - Browser back/forward navigation support
* - Custom event dispatch for TOC updates
*
* Usage:
* <div class="api-tabs-wrapper" data-component="api-tabs">
* <div class="api-tabs-nav">
* <a href="#operations" data-tab="operations" class="is-active">Operations</a>
* <a href="#auth" data-tab="auth">Authentication</a>
* </div>
* </div>
* <div class="api-tab-panels">
* <section data-tab-panel="operations">...</section>
* <section data-tab-panel="auth" style="display:none">...</section>
* </div>
*/
interface ComponentOptions {
component: HTMLElement;
}
/**
* Find the panels container (sibling element after tabs)
*/
function findPanelsContainer(tabsWrapper: HTMLElement): HTMLElement | null {
let sibling = tabsWrapper.nextElementSibling;
while (sibling) {
if (sibling.classList.contains('api-tab-panels')) {
return sibling as HTMLElement;
}
sibling = sibling.nextElementSibling;
}
return null;
}
/**
* Switch to a specific tab
*/
function switchTab(
tabsWrapper: HTMLElement,
panelsContainer: HTMLElement,
tabId: string,
updateHash = true
): void {
// Update active tab
const tabs = tabsWrapper.querySelectorAll<HTMLAnchorElement>('[data-tab]');
tabs.forEach((tab) => {
if (tab.dataset.tab === tabId) {
tab.classList.add('is-active');
} else {
tab.classList.remove('is-active');
}
});
// Update visible panel
const panels =
panelsContainer.querySelectorAll<HTMLElement>('[data-tab-panel]');
panels.forEach((panel) => {
if (panel.dataset.tabPanel === tabId) {
panel.style.display = 'block';
} else {
panel.style.display = 'none';
}
});
// Update URL hash without scrolling
if (updateHash) {
history.replaceState(null, '', '#' + tabId);
}
// Dispatch custom event for TOC update
document.dispatchEvent(
new CustomEvent('api-tab-change', { detail: { tab: tabId } })
);
}
/**
* Get tab ID from URL hash
*/
function getTabFromHash(): string | null {
const hash = window.location.hash.substring(1);
return hash || null;
}
/**
* Initialize API Tabs component
*/
export default function ApiTabs({ component }: ComponentOptions): void {
const panelsContainer = findPanelsContainer(component);
if (!panelsContainer) {
console.warn('[API Tabs] No .api-tab-panels container found');
return;
}
const tabs = component.querySelectorAll<HTMLAnchorElement>('[data-tab]');
if (tabs.length === 0) {
console.warn('[API Tabs] No tabs found with data-tab attribute');
return;
}
// Handle tab clicks
tabs.forEach((tab) => {
tab.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation(); // Prevent other tab handlers from firing
const tabId = tab.dataset.tab;
if (tabId) {
switchTab(component, panelsContainer, tabId);
}
});
});
// Handle deep linking via URL hash on load
const hashTab = getTabFromHash();
if (hashTab) {
const matchingTab = component.querySelector(`[data-tab="${hashTab}"]`);
if (matchingTab) {
switchTab(component, panelsContainer, hashTab, false);
}
}
// Handle browser back/forward navigation
window.addEventListener('hashchange', () => {
const newTabId = getTabFromHash();
if (newTabId) {
const matchingTab = component.querySelector(`[data-tab="${newTabId}"]`);
if (matchingTab) {
switchTab(component, panelsContainer, newTabId, false);
}
}
});
}

View File

@ -0,0 +1,434 @@
/**
* API Table of Contents Component
*
* Generates "ON THIS PAGE" navigation from content headings or operations data.
* Features:
* - Builds TOC from h2/h3 headings in the active tab panel (legacy)
* - Builds TOC from operations data passed via data-operations attribute (tag-based)
* - Highlights current section on scroll (intersection observer)
* - Smooth scroll to anchors
* - Updates when tab changes
*
* Usage:
* <aside class="api-toc" data-component="api-toc" data-operations='[...]'>
* <h4 class="api-toc-header">ON THIS PAGE</h4>
* <nav class="api-toc-nav"></nav>
* </aside>
*/
interface ComponentOptions {
component: HTMLElement;
}
interface TocEntry {
id: string;
text: string;
level: number;
}
/**
* Operation metadata from frontmatter (for tag-based pages)
*/
interface OperationMeta {
operationId: string;
method: string;
path: string;
summary: string;
tags: string[];
}
/**
* Check if the active panel contains a RapiDoc component
*/
function isRapiDocActive(): boolean {
const activePanel = document.querySelector(
'.tab-content:not([style*="display: none"]), [data-tab-panel]:not([style*="display: none"])'
);
return activePanel?.querySelector('rapi-doc') !== null;
}
/**
* Get headings from the currently visible content
*/
function getVisibleHeadings(): TocEntry[] {
// Find the active tab panel or main content area
const activePanel = document.querySelector(
'.tab-content:not([style*="display: none"]), [data-tab-panel]:not([style*="display: none"]), .article--content'
);
if (!activePanel) {
return [];
}
const headings = activePanel.querySelectorAll('h2, h3');
const entries: TocEntry[] = [];
headings.forEach((heading) => {
// Skip headings without IDs
if (!heading.id) {
return;
}
// Skip hidden headings
const rect = heading.getBoundingClientRect();
if (rect.width === 0 && rect.height === 0) {
return;
}
entries.push({
id: heading.id,
text: heading.textContent?.trim() || '',
level: heading.tagName === 'H2' ? 2 : 3,
});
});
return entries;
}
/**
* Build TOC HTML from entries
*/
function buildTocHtml(entries: TocEntry[]): string {
if (entries.length === 0) {
// Check if RapiDoc is active - show helpful message
if (isRapiDocActive()) {
return '<p class="api-toc-empty">Use RapiDoc\'s navigation below to explore this endpoint.</p>';
}
return '<p class="api-toc-empty">No sections on this page.</p>';
}
let html = '<ul class="api-toc-list">';
entries.forEach((entry) => {
const indent = entry.level === 3 ? ' api-toc-item--nested' : '';
html += `
<li class="api-toc-item${indent}">
<a href="#${entry.id}" class="api-toc-link">${entry.text}</a>
</li>
`;
});
html += '</ul>';
return html;
}
/**
* Get method badge class for HTTP method
*/
function getMethodClass(method: string): string {
const m = method.toLowerCase();
switch (m) {
case 'get':
return 'api-method--get';
case 'post':
return 'api-method--post';
case 'put':
return 'api-method--put';
case 'patch':
return 'api-method--patch';
case 'delete':
return 'api-method--delete';
default:
return '';
}
}
/**
* Build TOC HTML from operations data (for tag-based pages)
*/
function buildOperationsTocHtml(operations: OperationMeta[]): string {
if (operations.length === 0) {
return '<p class="api-toc-empty">No operations on this page.</p>';
}
let html = '<ul class="api-toc-list api-toc-list--operations">';
operations.forEach((op) => {
// Generate anchor ID from operationId (Scalar uses operationId for anchors)
const anchorId = op.operationId;
const methodClass = getMethodClass(op.method);
html += `
<li class="api-toc-item api-toc-item--operation">
<a href="#${anchorId}" class="api-toc-link api-toc-link--operation">
<span class="api-method ${methodClass}">${op.method.toUpperCase()}</span>
<span class="api-path">${op.path}</span>
</a>
</li>
`;
});
html += '</ul>';
return html;
}
/**
* Parse operations from data attribute
*/
function parseOperationsData(component: HTMLElement): OperationMeta[] | null {
const dataAttr = component.getAttribute('data-operations');
if (!dataAttr) {
return null;
}
try {
const operations = JSON.parse(dataAttr) as OperationMeta[];
return Array.isArray(operations) ? operations : null;
} catch (e) {
console.warn('[API TOC] Failed to parse operations data:', e);
return null;
}
}
/**
* Set up intersection observer for scroll highlighting
*/
function setupScrollHighlighting(
container: HTMLElement,
entries: TocEntry[]
): IntersectionObserver | null {
if (entries.length === 0) {
return null;
}
const headingIds = entries.map((e) => e.id);
const links = container.querySelectorAll<HTMLAnchorElement>('.api-toc-link');
// Create a map of heading ID to link element
const linkMap = new Map<string, HTMLAnchorElement>();
links.forEach((link) => {
const href = link.getAttribute('href');
if (href?.startsWith('#')) {
linkMap.set(href.slice(1), link);
}
});
// Track which headings are visible
const visibleHeadings = new Set<string>();
const observer = new IntersectionObserver(
(observerEntries) => {
observerEntries.forEach((entry) => {
const id = entry.target.id;
if (entry.isIntersecting) {
visibleHeadings.add(id);
} else {
visibleHeadings.delete(id);
}
});
// Find the first visible heading (in document order)
let activeId: string | null = null;
for (const id of headingIds) {
if (visibleHeadings.has(id)) {
activeId = id;
break;
}
}
// If no heading is visible, use the last one that was scrolled past
if (!activeId && visibleHeadings.size === 0) {
const scrollY = window.scrollY;
for (let i = headingIds.length - 1; i >= 0; i--) {
const heading = document.getElementById(headingIds[i]);
if (heading && heading.offsetTop < scrollY + 100) {
activeId = headingIds[i];
break;
}
}
}
// Update active state on links
links.forEach((link) => {
link.classList.remove('is-active');
});
if (activeId) {
const activeLink = linkMap.get(activeId);
activeLink?.classList.add('is-active');
}
},
{
rootMargin: '-80px 0px -70% 0px',
threshold: 0,
}
);
// Observe all headings
headingIds.forEach((id) => {
const heading = document.getElementById(id);
if (heading) {
observer.observe(heading);
}
});
return observer;
}
/**
* Set up smooth scroll for TOC links
*/
function setupSmoothScroll(container: HTMLElement): void {
container.addEventListener('click', (event) => {
const target = event.target as HTMLElement;
const link = target.closest<HTMLAnchorElement>('.api-toc-link');
if (!link) {
return;
}
const href = link.getAttribute('href');
if (!href?.startsWith('#')) {
return;
}
const targetElement = document.getElementById(href.slice(1));
if (!targetElement) {
return;
}
event.preventDefault();
// Scroll with offset for fixed header
const headerOffset = 80;
const elementPosition = targetElement.getBoundingClientRect().top;
const offsetPosition = elementPosition + window.scrollY - headerOffset;
window.scrollTo({
top: offsetPosition,
behavior: 'smooth',
});
// Update URL hash without jumping
history.pushState(null, '', href);
});
}
/**
* Update TOC visibility based on active tab
* Hide TOC for Operations tab (RapiDoc has built-in navigation)
*/
function updateTocVisibility(container: HTMLElement): void {
const operationsPanel = document.querySelector(
'[data-tab-panel="operations"]'
);
const isOperationsVisible =
operationsPanel &&
!operationsPanel.getAttribute('style')?.includes('display: none');
if (isOperationsVisible) {
container.classList.add('is-hidden');
} else {
container.classList.remove('is-hidden');
}
}
/**
* Watch for tab changes to rebuild TOC
*/
function watchTabChanges(
container: HTMLElement,
rebuild: () => void
): MutationObserver {
const tabPanels = document.querySelector('.api-tab-panels');
if (!tabPanels) {
return new MutationObserver(() => {});
}
const observer = new MutationObserver((mutations) => {
// Check if any tab panel visibility changed
const hasVisibilityChange = mutations.some((mutation) => {
return (
mutation.type === 'attributes' &&
(mutation.attributeName === 'style' ||
mutation.attributeName === 'class')
);
});
if (hasVisibilityChange) {
// Update visibility based on active tab
updateTocVisibility(container);
// Debounce rebuild
setTimeout(rebuild, 100);
}
});
observer.observe(tabPanels, {
attributes: true,
subtree: true,
attributeFilter: ['style', 'class'],
});
return observer;
}
/**
* Initialize API TOC component
*/
export default function ApiToc({ component }: ComponentOptions): void {
const nav = component.querySelector<HTMLElement>('.api-toc-nav');
if (!nav) {
console.warn('[API TOC] No .api-toc-nav element found');
return;
}
// Check for operations data (tag-based pages)
const operations = parseOperationsData(component);
let observer: IntersectionObserver | null = null;
/**
* Rebuild the TOC
*/
function rebuild(): void {
// Clean up previous observer
if (observer) {
observer.disconnect();
observer = null;
}
// If operations data is present, build operations-based TOC
if (operations && operations.length > 0) {
if (nav) {
nav.innerHTML = buildOperationsTocHtml(operations);
}
// Don't hide TOC for tag-based pages - always show operations
component.classList.remove('is-hidden');
return;
}
// Otherwise, fall back to heading-based TOC
const entries = getVisibleHeadings();
if (nav) {
nav.innerHTML = buildTocHtml(entries);
}
// Set up scroll highlighting
observer = setupScrollHighlighting(component, entries);
}
// Check initial visibility (hide for Operations tab, only for non-operations pages)
if (!operations || operations.length === 0) {
updateTocVisibility(component);
}
// Initial build
rebuild();
// Set up smooth scroll
setupSmoothScroll(component);
// Watch for tab changes (only for non-operations pages)
if (!operations || operations.length === 0) {
watchTabChanges(component, rebuild);
}
// Also rebuild on window resize (headings may change visibility)
let resizeTimeout: number;
window.addEventListener('resize', () => {
clearTimeout(resizeTimeout);
resizeTimeout = window.setTimeout(rebuild, 250);
});
}

View File

@ -46,6 +46,10 @@ import SidebarSearch from './components/sidebar-search.js';
import { SidebarToggle } from './sidebar-toggle.js';
import Theme from './theme.js';
import ThemeSwitch from './theme-switch.js';
import ApiNav from './components/api-nav.ts';
import ApiScalar from './components/api-scalar.ts';
import ApiTabs from './components/api-tabs.ts';
import ApiToc from './components/api-toc.ts';
/**
* Component Registry
@ -77,6 +81,10 @@ const componentRegistry = {
'sidebar-toggle': SidebarToggle,
theme: Theme,
'theme-switch': ThemeSwitch,
'api-nav': ApiNav,
'api-scalar': ApiScalar,
'api-tabs': ApiTabs,
'api-toc': ApiToc,
};
/**