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.jsworktree-2025-12-30T19-16-55
parent
14e5312cbc
commit
40cf280c5e
|
|
@ -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;
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -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.');
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
|
|||
Loading…
Reference in New Issue