diff --git a/assets/js/components/api-scalar.ts b/assets/js/components/api-scalar.ts deleted file mode 100644 index 62161a214..000000000 --- a/assets/js/components/api-scalar.ts +++ /dev/null @@ -1,326 +0,0 @@ -/** - * 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/main.js b/assets/js/main.js index fb5e79b27..c703d6309 100644 --- a/assets/js/main.js +++ b/assets/js/main.js @@ -47,7 +47,6 @@ import { SidebarToggle } from './sidebar-toggle.js'; import Theme from './theme.js'; import ThemeSwitch from './theme-switch.js'; import ApiRapiDoc from './components/api-rapidoc.ts'; -import ApiScalar from './components/api-scalar.ts'; import ApiTabs from './components/api-tabs.ts'; import ApiToc from './components/api-toc.ts'; import RapiDocMini from './components/rapidoc-mini.ts'; @@ -83,7 +82,6 @@ const componentRegistry = { theme: Theme, 'theme-switch': ThemeSwitch, 'api-rapidoc': ApiRapiDoc, - 'api-scalar': ApiScalar, 'api-tabs': ApiTabs, 'api-toc': ApiToc, 'rapidoc-mini': RapiDocMini, diff --git a/config/_default/hugo.yml b/config/_default/hugo.yml index 12b0f0ad1..c576413cd 100644 --- a/config/_default/hugo.yml +++ b/config/_default/hugo.yml @@ -98,8 +98,6 @@ module: params: env: development environment: development - # API documentation renderer: "scalar" (default) or "rapidoc" - apiRenderer: rapidoc # Configure the server for development server: diff --git a/docs/plans/2024-12-12-api-code-review-fixes.md b/docs/plans/2024-12-12-api-code-review-fixes.md new file mode 100644 index 000000000..6360c9f69 --- /dev/null +++ b/docs/plans/2024-12-12-api-code-review-fixes.md @@ -0,0 +1,779 @@ +# API Code Review Fixes Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Fix code review violations by extracting inline JavaScript from rapidoc.html into a TypeScript component and removing unused Scalar renderer code. + +**Architecture:** Create a new `api-rapidoc.ts` TypeScript component following the established component pattern (same as `rapidoc-mini.ts`). The component handles theme synchronization, shadow DOM manipulation, and MutationObserver setup. Remove the Scalar renderer, api-tabs component, and associated partials since they're no longer used. + +**Tech Stack:** TypeScript, Hugo templates, SCSS, Cypress + +*** + +## Task 1: Create api-rapidoc.ts TypeScript Component + +**Files:** + +- Create: `assets/js/components/api-rapidoc.ts` + +**Step 1: Create the TypeScript component file** + +Create `assets/js/components/api-rapidoc.ts` with the following content: + +```typescript +/** + * RapiDoc API Documentation Component + * + * Initializes the full RapiDoc renderer with theme synchronization. + * This is the component version of the inline JavaScript from rapidoc.html. + * + * Features: + * - Theme detection from Hugo's stylesheet toggle system + * - Automatic theme synchronization when user toggles dark/light mode + * - Shadow DOM manipulation to hide unwanted UI elements + * - CSS custom property injection for styling + * + * Usage: + *
+ * + * The component expects a element to already exist in the container + * (created by Hugo template) or will wait for it to be added. + */ + +import { getPreference } from '../services/local-storage.js'; + +interface ComponentOptions { + component: HTMLElement; +} + +interface ThemeColors { + theme: 'light' | 'dark'; + bgColor: string; + textColor: string; + headerColor: string; + primaryColor: string; + navBgColor: string; + navTextColor: string; + navHoverBgColor: string; + navHoverTextColor: string; + navAccentColor: string; + codeTheme: string; +} + +type CleanupFn = () => void; + +/** + * 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'; +} + +/** + * Get theme colors matching Hugo SCSS variables + */ +function getThemeColors(isDark: boolean): ThemeColors { + if (isDark) { + return { + theme: 'dark', + bgColor: '#14141F', // $grey10 ($article-bg in dark theme) + textColor: '#D4D7DD', // $g15-platinum + headerColor: '#D4D7DD', + primaryColor: '#a0a0ff', + navBgColor: '#1a1a2a', + navTextColor: '#D4D7DD', + navHoverBgColor: '#252535', + navHoverTextColor: '#ffffff', + navAccentColor: '#a0a0ff', + codeTheme: 'monokai', + }; + } + + return { + theme: 'light', + bgColor: '#ffffff', // $g20-white + textColor: '#2b2b2b', + headerColor: '#020a47', // $br-dark-blue + primaryColor: '#020a47', + navBgColor: '#f7f8fa', + navTextColor: '#2b2b2b', + navHoverBgColor: '#e8e8f0', + navHoverTextColor: '#020a47', + navAccentColor: '#020a47', + codeTheme: 'prism', + }; +} + +/** + * Apply theme to RapiDoc element + */ +function applyTheme(rapiDoc: HTMLElement): void { + const isDark = getTheme() === 'dark'; + const colors = getThemeColors(isDark); + + rapiDoc.setAttribute('theme', colors.theme); + rapiDoc.setAttribute('bg-color', colors.bgColor); + rapiDoc.setAttribute('text-color', colors.textColor); + rapiDoc.setAttribute('header-color', colors.headerColor); + rapiDoc.setAttribute('primary-color', colors.primaryColor); + rapiDoc.setAttribute('nav-bg-color', colors.navBgColor); + rapiDoc.setAttribute('nav-text-color', colors.navTextColor); + rapiDoc.setAttribute('nav-hover-bg-color', colors.navHoverBgColor); + rapiDoc.setAttribute('nav-hover-text-color', colors.navHoverTextColor); + rapiDoc.setAttribute('nav-accent-color', colors.navAccentColor); + rapiDoc.setAttribute('code-theme', colors.codeTheme); +} + +/** + * Set custom CSS properties on RapiDoc element + */ +function setInputBorderStyles(rapiDoc: HTMLElement): void { + rapiDoc.style.setProperty('--border-color', '#00A3FF'); +} + +/** + * Hide unwanted elements in RapiDoc shadow DOM + */ +function hideExpandCollapseControls(rapiDoc: HTMLElement): void { + const maxAttempts = 10; + let attempts = 0; + + const tryHide = (): void => { + attempts++; + + try { + const shadowRoot = rapiDoc.shadowRoot; + if (!shadowRoot) { + if (attempts < maxAttempts) { + setTimeout(tryHide, 500); + } + return; + } + + // Find all elements and hide those containing "Expand all" / "Collapse all" + const allElements = shadowRoot.querySelectorAll('*'); + let hiddenCount = 0; + + allElements.forEach((element) => { + const text = element.textContent || ''; + + if (text.includes('Expand all') || text.includes('Collapse all')) { + (element as HTMLElement).style.display = 'none'; + if (element.parentElement) { + element.parentElement.style.display = 'none'; + } + hiddenCount++; + } + }); + + // Hide "Overview" headings + const headings = shadowRoot.querySelectorAll('h1, h2, h3, h4'); + headings.forEach((heading) => { + const text = (heading.textContent || '').trim(); + if (text.includes('Overview')) { + (heading as HTMLElement).style.display = 'none'; + hiddenCount++; + } + }); + + // Inject CSS as backup + const style = document.createElement('style'); + style.textContent = ` + .section-gap.section-tag, + [id*="overview"], + .regular-font.section-gap:empty, + h1:empty, h2:empty, h3:empty { + display: none !important; + } + `; + shadowRoot.appendChild(style); + + if (hiddenCount === 0 && attempts < maxAttempts) { + setTimeout(tryHide, 500); + } + } catch (e) { + if (attempts < maxAttempts) { + setTimeout(tryHide, 500); + } + } + }; + + setTimeout(tryHide, 500); +} + +/** + * Watch for theme changes via stylesheet toggle + */ +function watchThemeChanges(rapiDoc: HTMLElement): CleanupFn { + const handleThemeChange = (): void => { + applyTheme(rapiDoc); + }; + + // Watch stylesheet disabled attribute changes (Hugo theme.js toggles this) + const observer = new MutationObserver((mutations) => { + for (const mutation of mutations) { + if ( + mutation.type === 'attributes' && + mutation.target instanceof HTMLLinkElement && + mutation.target.title?.includes('theme') + ) { + handleThemeChange(); + break; + } + // Also watch data-theme changes as fallback + if (mutation.attributeName === 'data-theme') { + handleThemeChange(); + } + } + }); + + // Observe head for stylesheet changes + observer.observe(document.head, { + attributes: true, + attributeFilter: ['disabled'], + subtree: true, + }); + + // Observe documentElement for data-theme changes + observer.observe(document.documentElement, { + attributes: true, + attributeFilter: ['data-theme'], + }); + + return (): void => { + observer.disconnect(); + }; +} + +/** + * Initialize RapiDoc component + */ +export default function ApiRapiDoc({ + component, +}: ComponentOptions): CleanupFn | void { + // Find the rapi-doc element inside the container + const rapiDoc = component.querySelector('rapi-doc') as HTMLElement | null; + + if (!rapiDoc) { + console.warn('[API RapiDoc] No rapi-doc element found in container'); + return; + } + + // Apply initial theme + applyTheme(rapiDoc); + + // Set custom CSS properties + if (customElements && customElements.whenDefined) { + customElements.whenDefined('rapi-doc').then(() => { + setInputBorderStyles(rapiDoc); + setTimeout(() => setInputBorderStyles(rapiDoc), 500); + }); + } else { + setInputBorderStyles(rapiDoc); + setTimeout(() => setInputBorderStyles(rapiDoc), 500); + } + + // Hide unwanted UI elements + hideExpandCollapseControls(rapiDoc); + + // Watch for theme changes + return watchThemeChanges(rapiDoc); +} +``` + +**Step 2: Verify the file was created correctly** + +Run: `head -30 assets/js/components/api-rapidoc.ts` +Expected: File header and imports visible + +**Step 3: Commit** + +```bash +git add assets/js/components/api-rapidoc.ts +git commit -m "feat(api): Create api-rapidoc TypeScript component + +Extract inline JavaScript from rapidoc.html into a proper TypeScript +component following the established component pattern." +``` + +*** + +## Task 2: Register api-rapidoc Component in main.js + +**Files:** + +- Modify: `assets/js/main.js:49-88` + +**Step 1: Add import for ApiRapiDoc** + +Add this import after line 52 (after RapiDocMini import): + +```javascript +import ApiRapiDoc from './components/api-rapidoc.ts'; +``` + +**Step 2: Register component in componentRegistry** + +Add this entry in the componentRegistry object (after line 87, the 'rapidoc-mini' entry): + +```javascript + 'api-rapidoc': ApiRapiDoc, +``` + +**Step 3: Verify changes** + +Run: `grep -n "api-rapidoc\|ApiRapiDoc" assets/js/main.js` +Expected: Both the import and registry entry appear + +**Step 4: Commit** + +```bash +git add assets/js/main.js +git commit -m "feat(api): Register api-rapidoc component in main.js" +``` + +*** + +## Task 3: Update rapidoc.html to Use Component Pattern + +**Files:** + +- Modify: `layouts/partials/api/rapidoc.html` + +**Step 1: Replace inline JavaScript with data-component attribute** + +Replace the entire content of `layouts/partials/api/rapidoc.html` with: + +```html +{{/* + RapiDoc API Documentation Renderer + + Primary API documentation renderer using RapiDoc with "Mix your own HTML" slots. + See: https://rapidocweb.com/examples.html + + Required page params: + - staticFilePath: Path to the OpenAPI specification file + + Optional page params: + - operationId: Specific operation to display (renders only that operation) + - tag: Tag to filter operations by + + RapiDoc slots available for custom content: + - slot="header" - Custom header + - slot="footer" - Custom footer + - slot="overview" - Custom overview content + - slot="auth" - Custom authentication section + - slot="nav-logo" - Custom navigation logo +*/}} + +{{ $specPath := .Params.staticFilePath }} +{{ $specPathJSON := replace $specPath ".yaml" ".json" | replace ".yml" ".json" }} +{{ $operationId := .Params.operationId | default "" }} +{{ $tag := .Params.tag | default "" }} + +{{/* Machine-readable links for AI agent discovery */}} +{{ if $specPath }} + + +{{ end }} + +
+ {{/* RapiDoc component with slot-based customization */}} + + {{/* Custom overview slot - Hugo page content */}} + {{ with .Content }} +
+ {{ . }} +
+ {{ end }} + + {{/* Custom examples from frontmatter */}} + {{ with .Params.examples }} +
+

Examples

+ {{ range . }} +
+

{{ .title }}

+ {{ with .description }}

{{ . | markdownify }}

{{ end }} +
{{ .code }}
+
+ {{ end }} +
+ {{ end }} +
+
+ +{{/* Load RapiDoc from CDN */}} + + + +``` + +**Step 2: Verify the inline script is removed** + +Run: `grep -c "