docs-v2/assets/js/components/api-toc.ts

474 lines
12 KiB
TypeScript

/**
* API Table of Contents Component
*
* Generates "ON THIS PAGE" navigation from content headings or operations data.
* Features:
* - Builds TOC from h2 headings by default
* - 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>
*
* Attributes:
* - data-operations: JSON array of operation objects for server-rendered TOC
* - data-toc-depth: Max heading level to include (default: "2", use "3" for h2+h3)
*/
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[];
}
/**
* Get headings from the currently visible content
*
* @param maxLevel - Maximum heading level to include (default: 2)
*/
function getVisibleHeadings(maxLevel: number = 2): 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 [];
}
// Build selector based on maxLevel (e.g., 'h2' or 'h2, h3')
const selectors = [];
for (let level = 2; level <= maxLevel; level++) {
selectors.push(`h${level}`);
}
const headings = activePanel.querySelectorAll(selectors.join(', '));
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;
}
const level = parseInt(heading.tagName.charAt(1), 10);
entries.push({
id: heading.id,
text: heading.textContent?.trim() || '',
level,
});
});
return entries;
}
/**
* Build TOC HTML from entries
*/
function buildTocHtml(entries: TocEntry[]): string {
if (entries.length === 0) {
// Return empty string - the TOC container can be hidden via CSS when empty
return '';
}
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 matching Redocly operation/{operationId} format
const anchorId = `operation/${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
*/
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 if TOC was pre-rendered server-side (has existing links)
const hasServerRenderedToc = nav.querySelectorAll('.api-toc-link').length > 0;
if (hasServerRenderedToc) {
// Server-side TOC exists - show it, set up navigation and scroll highlighting
component.classList.remove('is-hidden');
setupSmoothScroll(component);
// Extract entries from pre-rendered links for scroll highlighting
const preRenderedLinks =
nav.querySelectorAll<HTMLAnchorElement>('.api-toc-link');
const preRenderedEntries: TocEntry[] = [];
preRenderedLinks.forEach((link) => {
const href = link.getAttribute('href');
if (href?.startsWith('#')) {
preRenderedEntries.push({
id: href.slice(1),
text: link.textContent?.trim() || '',
level: 2,
});
}
});
if (preRenderedEntries.length > 0) {
setupScrollHighlighting(component, preRenderedEntries);
}
return;
}
// Check for operations data (tag-based pages)
const operations = parseOperationsData(component);
let observer: IntersectionObserver | null = null;
// Get max heading level from data attribute (default: 2)
// Use data-toc-depth="3" to include h3 headings if needed
const maxHeadingLevel = parseInt(
component.getAttribute('data-toc-depth') || '2',
10
);
/**
* 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(maxHeadingLevel);
if (nav) {
nav.innerHTML = buildTocHtml(entries);
}
// Hide TOC if no entries, show if entries exist
if (entries.length === 0) {
component.classList.add('is-hidden');
} else {
component.classList.remove('is-hidden');
// Set up scroll highlighting only when we have entries
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);
});
}