('[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,
};
/**