diff --git a/assets/js/components/api-nav.ts b/assets/js/components/api-nav.ts new file mode 100644 index 000000000..876ca8092 --- /dev/null +++ b/assets/js/components/api-nav.ts @@ -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: + * + */ + +interface ComponentOptions { + component: HTMLElement; +} + +/** + * Initialize API Navigation component + */ +export default function ApiNav({ component }: ComponentOptions): void { + const headers = component.querySelectorAll( + '.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; + } + }); + }); +} diff --git a/assets/js/components/api-scalar.ts b/assets/js/components/api-scalar.ts new file mode 100644 index 000000000..62161a214 --- /dev/null +++ b/assets/js/components/api-scalar.ts @@ -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: + *
+ */ + +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 { + 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 { + 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 { + 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 = `

${message}

`; +} + +/** + * 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 { + 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.'); + } +} diff --git a/assets/js/components/api-tabs.ts b/assets/js/components/api-tabs.ts new file mode 100644 index 000000000..25b41ec16 --- /dev/null +++ b/assets/js/components/api-tabs.ts @@ -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: + *
+ * + *
+ *
+ *
...
+ *
...
+ *
+ */ + +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('[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('[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('[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); + } + } + }); +} diff --git a/assets/js/components/api-toc.ts b/assets/js/components/api-toc.ts new file mode 100644 index 000000000..06b0d2f58 --- /dev/null +++ b/assets/js/components/api-toc.ts @@ -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: + * + */ + +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 '

Use RapiDoc\'s navigation below to explore this endpoint.

'; + } + return '

No sections on this page.

'; + } + + let html = '
    '; + + entries.forEach((entry) => { + const indent = entry.level === 3 ? ' api-toc-item--nested' : ''; + html += ` +
  • + ${entry.text} +
  • + `; + }); + + html += '
'; + 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 '

No operations on this page.

'; + } + + let html = '
    '; + + operations.forEach((op) => { + // Generate anchor ID from operationId (Scalar uses operationId for anchors) + const anchorId = op.operationId; + const methodClass = getMethodClass(op.method); + + html += ` +
  • + + ${op.method.toUpperCase()} + ${op.path} + +
  • + `; + }); + + html += '
'; + 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('.api-toc-link'); + + // Create a map of heading ID to link element + const linkMap = new Map(); + 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(); + + 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('.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('.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); + }); +} diff --git a/assets/js/main.js b/assets/js/main.js index 826ad9a11..a3cfbaddc 100644 --- a/assets/js/main.js +++ b/assets/js/main.js @@ -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, }; /**