/** * Licensed to the Apache Software Foundation (ASF) under one * or more contributor license agreements. See the NOTICE file * distributed with this work for additional information * regarding copyright ownership. The ASF licenses this file * to you under the Apache License, Version 2.0 (the * "License"); you may not use this file except in compliance * with the License. You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, * software distributed under the License is distributed on an * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * KIND, either express or implied. See the License for the * specific language governing permissions and limitations * under the License. */ /** * ============================================================================ * PHILOSOPHY: STORIES ARE THE SINGLE SOURCE OF TRUTH * ============================================================================ * * When something doesn't render correctly in the docs, FIX THE STORY FIRST. * Do NOT add special cases or workarounds to this generator. * * This generator should be as lightweight as possible - it extracts data from * stories and passes it through to MDX. All configuration belongs in stories: * * - Use `export default { title: '...' }` (inline export, not variable) * - Name stories `Interactive${ComponentName}` for docs generation * - Define `args` and `argTypes` at the story level (not meta level) * - Use `parameters.docs.gallery` for variant grids * - Use `parameters.docs.sampleChildren` for components needing children * - Use `parameters.docs.liveExample` for custom code examples * - Use `parameters.docs.staticProps` for complex props * * If a story doesn't work with this generator, fix the story to match the * expected patterns rather than adding complexity here. * ============================================================================ */ /** * This script scans for ALL Storybook stories and generates MDX documentation * pages for the "Superset Components" section of the developer portal. * * Supports multiple source directories with different import paths and categories. * * Usage: node scripts/generate-superset-components.mjs */ import fs from 'fs'; import path from 'path'; import { fileURLToPath } from 'url'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const ROOT_DIR = path.resolve(__dirname, '../..'); const DOCS_DIR = path.resolve(__dirname, '..'); const OUTPUT_DIR = path.join(DOCS_DIR, 'developer_portal/components'); const FRONTEND_DIR = path.join(ROOT_DIR, 'superset-frontend'); // Source configurations with import paths and categories const SOURCES = [ { name: 'UI Core Components', path: 'packages/superset-ui-core/src/components', importPrefix: '@superset/components', docImportPrefix: '@superset-ui/core/components', category: 'ui', enabled: true, // Components that require complex function props or aren't exported properly skipComponents: new Set([ // Complex function props (require callbacks, async data, or render props) 'AsyncSelect', 'ConfirmStatusChange', 'CronPicker', 'LabeledErrorBoundInput', 'AsyncAceEditor', 'AsyncEsmComponent', 'TimezoneSelector', // Not exported from @superset/components index or have export mismatches 'ActionCell', 'BooleanCell', 'ButtonCell', 'NullCell', 'NumericCell', 'TimeCell', 'CertifiedBadgeWithTooltip', 'CodeSyntaxHighlighter', 'DynamicTooltip', 'PopoverDropdown', 'PopoverSection', 'WarningIconWithTooltip', 'RefreshLabel', // Components with complex nested props (JSX children, overlay, items arrays) 'Dropdown', 'DropdownButton', ]), }, { name: 'App Components', path: 'src/components', importPrefix: 'src/components', docImportPrefix: 'src/components', category: 'app', enabled: false, // Requires app context (Redux, routing, etc.) skipComponents: new Set([]), }, { name: 'Dashboard Components', path: 'src/dashboard/components', importPrefix: 'src/dashboard/components', docImportPrefix: 'src/dashboard/components', category: 'dashboard', enabled: false, // Requires app context skipComponents: new Set([]), }, { name: 'Explore Components', path: 'src/explore/components', importPrefix: 'src/explore/components', docImportPrefix: 'src/explore/components', category: 'explore', enabled: false, // Requires app context skipComponents: new Set([]), }, { name: 'Feature Components', path: 'src/features', importPrefix: 'src/features', docImportPrefix: 'src/features', category: 'features', enabled: false, // Requires app context skipComponents: new Set([]), }, { name: 'Filter Components', path: 'src/filters/components', importPrefix: 'src/filters/components', docImportPrefix: 'src/filters/components', category: 'filters', enabled: false, // Requires app context skipComponents: new Set([]), }, { name: 'Chart Plugins', path: 'packages/superset-ui-demo/storybook/stories/plugins', importPrefix: '@superset-ui/demo', docImportPrefix: '@superset-ui/demo', category: 'chart-plugins', enabled: false, // Requires chart infrastructure skipComponents: new Set([]), }, { name: 'Core Packages', path: 'packages/superset-ui-demo/storybook/stories/superset-ui-chart', importPrefix: '@superset-ui/core', docImportPrefix: '@superset-ui/core', category: 'core-packages', enabled: false, // Requires specific setup skipComponents: new Set([]), }, ]; // Category mapping from story title prefixes to output directories const CATEGORY_MAP = { 'Components/': 'ui', 'Design System/': 'design-system', 'Chart Plugins/': 'chart-plugins', 'Legacy Chart Plugins/': 'legacy-charts', 'Core Packages/': 'core-packages', 'Others/': 'utilities', 'Extension Components/': 'extension', // Skip - handled by other script 'Superset App/': 'app', }; // Documentation-only stories to skip (not actual components) const SKIP_STORIES = [ 'Introduction', // Design System intro page 'Overview', // Category overview pages 'Examples', // Example collections 'DesignSystem', // Meta design system page 'MetadataBarOverview', // Overview page 'TableOverview', // Overview page 'Filter Plugins', // Collection story, not a component ]; /** * Recursively find all story files in a directory */ function walkDir(dir, files = []) { if (!fs.existsSync(dir)) return files; const entries = fs.readdirSync(dir, { withFileTypes: true }); for (const entry of entries) { const fullPath = path.join(dir, entry.name); if (entry.isDirectory()) { walkDir(fullPath, files); } else if (entry.name.endsWith('.stories.tsx') || entry.name.endsWith('.stories.ts')) { files.push(fullPath); } } return files; } /** * Find all story files from enabled sources */ function findEnabledStoryFiles() { const files = []; for (const source of SOURCES.filter(s => s.enabled)) { const dir = path.join(FRONTEND_DIR, source.path); const sourceFiles = walkDir(dir, []); // Attach source config to each file for (const file of sourceFiles) { files.push({ file, source }); } } return files; } /** * Find all story files from disabled sources (for tracking) */ function findDisabledStoryFiles() { const files = []; for (const source of SOURCES.filter(s => !s.enabled)) { const dir = path.join(FRONTEND_DIR, source.path); walkDir(dir, files); } return files; } /** * Parse a story file and extract metadata */ function parseStoryFile(filePath, sourceConfig) { const content = fs.readFileSync(filePath, 'utf-8'); // Extract title from story meta (in export default block, not from data objects) // Look for title in the export default section, which typically starts with "export default {" const metaMatch = content.match(/export\s+default\s*\{[\s\S]*?title:\s*['"]([^'"]+)['"]/); const title = metaMatch ? metaMatch[1] : null; if (!title) return null; // Extract component name (last part of title path) const titleParts = title.split('/'); const componentName = titleParts.pop(); // Skip documentation-only stories if (SKIP_STORIES.includes(componentName)) { return null; } // Skip components in the source's skip list if (sourceConfig.skipComponents.has(componentName)) { return null; } // Determine category - use source's default category unless title has a specific prefix let category = sourceConfig.category; for (const [prefix, cat] of Object.entries(CATEGORY_MAP)) { if (title.startsWith(prefix)) { category = cat; break; } } // Extract description from parameters let description = ''; const descBlockMatch = content.match( /description:\s*{\s*component:\s*([\s\S]*?)\s*},?\s*}/ ); if (descBlockMatch) { const descBlock = descBlockMatch[1]; const stringParts = []; const stringMatches = descBlock.matchAll(/['"]([^'"]*)['"]/g); for (const match of stringMatches) { stringParts.push(match[1]); } description = stringParts.join('').trim(); } // Extract story exports const storyExports = []; const exportMatches = content.matchAll(/export\s+(?:const|function)\s+(\w+)/g); for (const match of exportMatches) { if (match[1] !== 'default') { storyExports.push(match[1]); } } // Extract component import path from the story file // Look for: import ComponentName from './path' (default export) // or: import { ComponentName } from './path' (named export) let componentImportPath = null; let isDefaultExport = true; // Try to find default import matching the component name // Handles: import Component from 'path' // and: import Component, { OtherExport } from 'path' const defaultImportMatch = content.match( new RegExp(`import\\s+${componentName}(?:\\s*,\\s*{[^}]*})?\\s+from\\s+['"]([^'"]+)['"]`) ); if (defaultImportMatch) { componentImportPath = defaultImportMatch[1]; isDefaultExport = true; } else { // Try named import const namedImportMatch = content.match( new RegExp(`import\\s*{[^}]*\\b${componentName}\\b[^}]*}\\s*from\\s+['"]([^'"]+)['"]`) ); if (namedImportMatch) { componentImportPath = namedImportMatch[1]; isDefaultExport = false; } } // Calculate full import path if we found a relative import // For UI core components with aliases, keep using the alias let resolvedImportPath = sourceConfig.importPrefix; const useAlias = sourceConfig.importPrefix.startsWith('@superset/'); if (componentImportPath && componentImportPath.startsWith('.') && !useAlias) { const storyDir = path.dirname(filePath); const resolvedPath = path.resolve(storyDir, componentImportPath); // Get path relative to frontend root, then convert to import path const frontendRelative = path.relative(FRONTEND_DIR, resolvedPath); resolvedImportPath = frontendRelative.replace(/\\/g, '/'); } else if (!componentImportPath && !useAlias) { // Fallback: assume component is in same dir as story, named same as component const storyDir = path.dirname(filePath); const possibleComponentPath = path.join(storyDir, componentName); const frontendRelative = path.relative(FRONTEND_DIR, possibleComponentPath); resolvedImportPath = frontendRelative.replace(/\\/g, '/'); } return { filePath, title, titleParts, componentName, category, description, storyExports, relativePath: path.relative(ROOT_DIR, filePath), sourceConfig, resolvedImportPath, isDefaultExport, }; } /** * Parse args content and extract key-value pairs * Handles strings with apostrophes correctly */ function parseArgsContent(argsContent, args) { // Split into lines and process each line for simple key-value pairs const lines = argsContent.split('\n'); for (let i = 0; i < lines.length; i++) { const trimmed = lines[i].trim(); if (!trimmed || trimmed.startsWith('//')) continue; // Match: key: value pattern at start of line const propMatch = trimmed.match(/^([a-zA-Z_$][a-zA-Z0-9_$]*):\s*(.+?)[\s,]*$/); // Also match key with value on the next line (e.g., prettier wrapping long strings) const keyOnlyMatch = !propMatch && trimmed.match(/^([a-zA-Z_$][a-zA-Z0-9_$]*):$/); if (!propMatch && !keyOnlyMatch) continue; let key, valueStr; if (propMatch) { key = propMatch[1]; valueStr = propMatch[2]; } else { // Value is on the next line key = keyOnlyMatch[1]; const nextLine = i + 1 < lines.length ? lines[i + 1].trim().replace(/,\s*$/, '') : ''; if (!nextLine) continue; valueStr = nextLine; i++; // Skip the next line since we consumed it } // Parse the value // Double-quoted string (handles apostrophes inside) const doubleQuoteMatch = valueStr.match(/^"([^"]*)"$/); if (doubleQuoteMatch) { args[key] = doubleQuoteMatch[1]; continue; } // Single-quoted string const singleQuoteMatch = valueStr.match(/^'([^']*)'$/); if (singleQuoteMatch) { args[key] = singleQuoteMatch[1]; continue; } // Template literal const templateMatch = valueStr.match(/^`([^`]*)`$/); if (templateMatch) { args[key] = templateMatch[1].replace(/\s+/g, ' ').trim(); continue; } // Boolean if (valueStr === 'true' || valueStr === 'true,') { args[key] = true; continue; } if (valueStr === 'false' || valueStr === 'false,') { args[key] = false; continue; } // Number (including decimals and negative) const numMatch = valueStr.match(/^(-?\d+\.?\d*),?$/); if (numMatch) { args[key] = Number(numMatch[1]); continue; } // Skip complex values (objects, arrays, function calls, expressions) } } /** * Extract variable arrays from file content (for options references) */ function extractVariableArrays(content) { const variableArrays = {}; // Pattern 1: const varName = ['a', 'b', 'c']; // Also handles: export const varName: Type[] = ['a', 'b', 'c']; const varMatches = content.matchAll(/(?:export\s+)?(?:const|let)\s+([a-zA-Z_$][a-zA-Z0-9_$]*)(?::\s*[^=]+)?\s*=\s*\[([^\]]+)\]/g); for (const varMatch of varMatches) { const varName = varMatch[1]; const arrayContent = varMatch[2]; const values = []; const valMatches = arrayContent.matchAll(/['"]([^'"]+)['"]/g); for (const val of valMatches) { values.push(val[1]); } if (values.length > 0) { variableArrays[varName] = values; } } // Pattern 2: const VAR = { options: [...] } - for SIZES.options, COLORS.options patterns const objWithOptionsMatches = content.matchAll(/(?:const|let)\s+([A-Z][A-Z_0-9]*)\s*=\s*\{[^}]*options:\s*([a-zA-Z_$][a-zA-Z0-9_$]*)/g); for (const match of objWithOptionsMatches) { const objName = match[1]; const optionsVarName = match[2]; // Link the object's options to the underlying array if (variableArrays[optionsVarName]) { variableArrays[objName] = variableArrays[optionsVarName]; } } return variableArrays; } /** * Extract a string value from content, handling quotes properly */ function extractStringValue(content, startIndex) { const remaining = content.slice(startIndex).trim(); // Single-quoted string if (remaining.startsWith("'")) { let i = 1; while (i < remaining.length) { if (remaining[i] === "'" && remaining[i - 1] !== '\\') { return remaining.slice(1, i); } i++; } } // Double-quoted string if (remaining.startsWith('"')) { let i = 1; while (i < remaining.length) { if (remaining[i] === '"' && remaining[i - 1] !== '\\') { return remaining.slice(1, i); } i++; } } // Template literal if (remaining.startsWith('`')) { let i = 1; while (i < remaining.length) { if (remaining[i] === '`' && remaining[i - 1] !== '\\') { return remaining.slice(1, i).replace(/\s+/g, ' ').trim(); } i++; } } return null; } /** * Parse argTypes content and populate the argTypes object */ function parseArgTypes(argTypesContent, argTypes, fullContent) { const variableArrays = extractVariableArrays(fullContent); // Match argType definitions - find each property block // Use balanced brace extraction for each property const propPattern = /([a-zA-Z_$][a-zA-Z0-9_$]*):\s*\{/g; let propMatch; while ((propMatch = propPattern.exec(argTypesContent)) !== null) { const propName = propMatch[1]; const propStartIndex = propMatch.index + propMatch[0].length - 1; const propConfig = extractBalancedBraces(argTypesContent, propStartIndex); if (!propConfig) continue; // Initialize argTypes entry if not exists if (!argTypes[propName]) { argTypes[propName] = {}; } // Extract description - find the position and extract properly const descIndex = propConfig.indexOf('description:'); if (descIndex !== -1) { const descValue = extractStringValue(propConfig, descIndex + 'description:'.length); if (descValue) { argTypes[propName].description = descValue; } } // Check for inline options array const optionsMatch = propConfig.match(/options:\s*\[([^\]]+)\]/); if (optionsMatch) { const optionsStr = optionsMatch[1]; const options = []; const optionMatches = optionsStr.matchAll(/['"]([^'"]+)['"]/g); for (const opt of optionMatches) { options.push(opt[1]); } if (options.length > 0) { argTypes[propName].type = 'select'; argTypes[propName].options = options; } } else { // Check for variable reference: options: variableName or options: VAR.options const varRefMatch = propConfig.match(/options:\s*([a-zA-Z_$][a-zA-Z0-9_$]*(?:\.[a-zA-Z_$][a-zA-Z0-9_$]*)?)/); if (varRefMatch) { const varRef = varRefMatch[1]; // Handle VAR.options pattern const dotMatch = varRef.match(/^([a-zA-Z_$][a-zA-Z0-9_$]*)\.options$/); if (dotMatch && variableArrays[dotMatch[1]]) { argTypes[propName].type = 'select'; argTypes[propName].options = variableArrays[dotMatch[1]]; } else if (variableArrays[varRef]) { argTypes[propName].type = 'select'; argTypes[propName].options = variableArrays[varRef]; } } else { // Check for ES6 shorthand: options, (same as options: options) const shorthandMatch = propConfig.match(/(?:^|[,\s])options(?:[,\s]|$)/); if (shorthandMatch && variableArrays['options']) { argTypes[propName].type = 'select'; argTypes[propName].options = variableArrays['options']; } } } // Check for control type (radio, select, boolean, etc.) // Supports both: control: 'boolean' (shorthand) and control: { type: 'boolean' } (object) const controlShorthandMatch = propConfig.match(/control:\s*['"]([^'"]+)['"]/); const controlObjectMatch = propConfig.match(/control:\s*\{[^}]*type:\s*['"]([^'"]+)['"]/); if (controlShorthandMatch) { argTypes[propName].type = controlShorthandMatch[1]; } else if (controlObjectMatch) { argTypes[propName].type = controlObjectMatch[1]; } // Clear options for non-select/radio types (the shorthand "options" detection // can false-positive when the word "options" appears in description text) const finalType = argTypes[propName].type; if (finalType && !['select', 'radio', 'inline-radio'].includes(finalType)) { delete argTypes[propName].options; } } } /** * Helper to find balanced braces content */ function extractBalancedBraces(content, startIndex) { let depth = 0; let start = -1; for (let i = startIndex; i < content.length; i++) { if (content[i] === '{') { if (depth === 0) start = i + 1; depth++; } else if (content[i] === '}') { depth--; if (depth === 0) { return content.slice(start, i); } } } return null; } /** * Helper to find balanced brackets content (for arrays) */ function extractBalancedBrackets(content, startIndex) { let depth = 0; let start = -1; for (let i = startIndex; i < content.length; i++) { if (content[i] === '[') { if (depth === 0) start = i + 1; depth++; } else if (content[i] === ']') { depth--; if (depth === 0) { return content.slice(start, i); } } } return null; } /** * Convert camelCase prop name to human-readable label * Handles acronyms properly: imgURL -> "Image URL", coverLeft -> "Cover Left" */ function propNameToLabel(name) { return name // Insert space before uppercase letters that follow lowercase (camelCase boundary) .replace(/([a-z])([A-Z])/g, '$1 $2') // Handle common acronyms - keep them together .replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2') // Capitalize first letter .replace(/^./, s => s.toUpperCase()) // Fix common acronyms display .replace(/\bUrl\b/g, 'URL') .replace(/\bImg\b/g, 'Image') .replace(/\bId\b/g, 'ID'); } /** * Convert JS object literal syntax to JSON * Handles: single quotes, unquoted keys, trailing commas */ function jsToJson(jsStr) { try { // Remove comments let str = jsStr.replace(/\/\/[^\n]*/g, '').replace(/\/\*[\s\S]*?\*\//g, ''); // Replace single quotes with double quotes (but not inside already double-quoted strings) str = str.replace(/'/g, '"'); // Add quotes around unquoted keys: { foo: -> { "foo": str = str.replace(/([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)(\s*:)/g, '$1"$2"$3'); // Remove trailing commas before } or ] str = str.replace(/,(\s*[}\]])/g, '$1'); return JSON.parse(str); } catch { return null; } } /** * Extract docs config from story parameters * Looks for: StoryName.parameters = { docs: { sampleChildren, sampleChildrenStyle, gallery, staticProps, liveExample } } * Uses generic JSON parsing for inline data */ function extractDocsConfig(content, storyNames) { // Extract variable arrays for gallery config (sizes, styles) const variableArrays = extractVariableArrays(content); let sampleChildren = null; let sampleChildrenStyle = null; let gallery = null; let staticProps = null; let liveExample = null; let examples = null; let renderComponent = null; let triggerProp = null; let onHideProp = null; for (const storyName of storyNames) { // Look for parameters block const parametersPattern = new RegExp(`${storyName}\\.parameters\\s*=\\s*\\{`, 's'); const parametersMatch = content.match(parametersPattern); if (parametersMatch) { const parametersContent = extractBalancedBraces(content, parametersMatch.index + parametersMatch[0].length - 1); if (parametersContent) { // Extract sampleChildren - inline array using generic JSON parser const sampleChildrenArrayMatch = parametersContent.match(/sampleChildren:\s*\[/); if (sampleChildrenArrayMatch) { const arrayStartIndex = sampleChildrenArrayMatch.index + sampleChildrenArrayMatch[0].length - 1; const arrayContent = extractBalancedBrackets(parametersContent, arrayStartIndex); if (arrayContent) { const parsed = jsToJson('[' + arrayContent + ']'); if (parsed && parsed.length > 0) { sampleChildren = parsed; } } } // Extract sampleChildrenStyle - inline object using generic JSON parser const sampleChildrenStyleMatch = parametersContent.match(/sampleChildrenStyle:\s*\{/); if (sampleChildrenStyleMatch) { const styleContent = extractBalancedBraces(parametersContent, sampleChildrenStyleMatch.index + sampleChildrenStyleMatch[0].length - 1); if (styleContent) { const parsed = jsToJson('{' + styleContent + '}'); if (parsed) { sampleChildrenStyle = parsed; } } } // Extract staticProps - generic JSON-like object extraction const staticPropsMatch = parametersContent.match(/staticProps:\s*\{/); if (staticPropsMatch) { const staticPropsContent = extractBalancedBraces(parametersContent, staticPropsMatch.index + staticPropsMatch[0].length - 1); if (staticPropsContent) { // Try to parse as JSON (handles inline data) const parsed = jsToJson('{' + staticPropsContent + '}'); if (parsed) { staticProps = parsed; } } } // Extract gallery config const galleryMatch = parametersContent.match(/gallery:\s*\{/); if (galleryMatch) { const galleryContent = extractBalancedBraces(parametersContent, galleryMatch.index + galleryMatch[0].length - 1); if (galleryContent) { gallery = {}; // Extract component name const compMatch = galleryContent.match(/component:\s*['"]([^'"]+)['"]/); if (compMatch) gallery.component = compMatch[1]; // Extract sizes - variable reference const sizesVarMatch = galleryContent.match(/sizes:\s*([a-zA-Z_$][a-zA-Z0-9_$]*)/); if (sizesVarMatch && variableArrays[sizesVarMatch[1]]) { gallery.sizes = variableArrays[sizesVarMatch[1]]; } // Extract styles - variable reference const stylesVarMatch = galleryContent.match(/styles:\s*([a-zA-Z_$][a-zA-Z0-9_$]*)/); if (stylesVarMatch && variableArrays[stylesVarMatch[1]]) { gallery.styles = variableArrays[stylesVarMatch[1]]; } // Extract sizeProp const sizePropMatch = galleryContent.match(/sizeProp:\s*['"]([^'"]+)['"]/); if (sizePropMatch) gallery.sizeProp = sizePropMatch[1]; // Extract styleProp const stylePropMatch = galleryContent.match(/styleProp:\s*['"]([^'"]+)['"]/); if (stylePropMatch) gallery.styleProp = stylePropMatch[1]; } } // Extract liveExample - template literal for custom live code block const liveExampleMatch = parametersContent.match(/liveExample:\s*`/); if (liveExampleMatch) { // Find the closing backtick const startIndex = liveExampleMatch.index + liveExampleMatch[0].length; let endIndex = startIndex; while (endIndex < parametersContent.length && parametersContent[endIndex] !== '`') { // Handle escaped backticks if (parametersContent[endIndex] === '\\' && parametersContent[endIndex + 1] === '`') { endIndex += 2; } else { endIndex++; } } if (endIndex < parametersContent.length) { // Unescape template literal escapes (source text has \` and \$ for literal backticks/dollars) liveExample = parametersContent.slice(startIndex, endIndex).replace(/\\`/g, '`').replace(/\\\$/g, '$'); } } // Extract renderComponent - allows overriding which component to render // Useful when the title-derived component (e.g., 'Icons') is a namespace, not a component const renderComponentMatch = parametersContent.match(/renderComponent:\s*['"]([^'"]+)['"]/); if (renderComponentMatch) { renderComponent = renderComponentMatch[1]; } // Extract triggerProp/onHideProp - for components like Modal that need a trigger button const triggerPropMatch = parametersContent.match(/triggerProp:\s*['"]([^'"]+)['"]/); if (triggerPropMatch) { triggerProp = triggerPropMatch[1]; } const onHidePropMatch = parametersContent.match(/onHideProp:\s*['"]([^'"]+)['"]/); if (onHidePropMatch) { onHideProp = onHidePropMatch[1]; } // Extract examples array - for multiple code examples // Format: examples: [{ title: 'Title', code: `...` }, ...] const examplesMatch = parametersContent.match(/examples:\s*\[/); if (examplesMatch) { const examplesStartIndex = examplesMatch.index + examplesMatch[0].length - 1; const examplesArrayContent = extractBalancedBrackets(parametersContent, examplesStartIndex); if (examplesArrayContent) { examples = []; // Find each example object { title: '...', code: `...` } const exampleObjPattern = /\{\s*title:\s*['"]([^'"]+)['"]\s*,\s*code:\s*`/g; let exampleMatch; while ((exampleMatch = exampleObjPattern.exec(examplesArrayContent)) !== null) { const title = exampleMatch[1]; const codeStartIndex = exampleMatch.index + exampleMatch[0].length; // Find closing backtick for code let codeEndIndex = codeStartIndex; while (codeEndIndex < examplesArrayContent.length && examplesArrayContent[codeEndIndex] !== '`') { if (examplesArrayContent[codeEndIndex] === '\\' && examplesArrayContent[codeEndIndex + 1] === '`') { codeEndIndex += 2; } else { codeEndIndex++; } } // Unescape template literal escapes (source text has \` and \$ for literal backticks/dollars) const code = examplesArrayContent.slice(codeStartIndex, codeEndIndex).replace(/\\`/g, '`').replace(/\\\$/g, '$'); examples.push({ title, code }); } } } } } if (sampleChildren || gallery || staticProps || liveExample || examples || renderComponent || triggerProp) break; } return { sampleChildren, sampleChildrenStyle, gallery, staticProps, liveExample, examples, renderComponent, triggerProp, onHideProp }; } /** * Extract args and controls from story content */ function extractArgsAndControls(content, componentName) { const args = {}; const argTypes = {}; // First, extract argTypes from the default export meta (shared across all stories) // Pattern: export default { argTypes: {...} } const defaultExportMatch = content.match(/export\s+default\s*\{/); if (defaultExportMatch) { const metaContent = extractBalancedBraces(content, defaultExportMatch.index + defaultExportMatch[0].length - 1); if (metaContent) { const metaArgTypesMatch = metaContent.match(/\bargTypes:\s*\{/); if (metaArgTypesMatch) { const metaArgTypesContent = extractBalancedBraces(metaContent, metaArgTypesMatch.index + metaArgTypesMatch[0].length - 1); if (metaArgTypesContent) { parseArgTypes(metaArgTypesContent, argTypes, content); } } } } // Then, try to find the Interactive story block (CSF 3.0 or CSF 2.0) // Support multiple naming conventions: // - InteractiveComponentName (CSF 2.0 convention) // - ComponentNameStory (CSF 3.0 convention) // - ComponentName (fallback) const storyNames = [`Interactive${componentName}`, `${componentName}Story`, componentName]; // Extract docs config (sampleChildren, sampleChildrenStyle, gallery, staticProps, liveExample) from parameters.docs const { sampleChildren, sampleChildrenStyle, gallery, staticProps, liveExample, examples, renderComponent, triggerProp, onHideProp } = extractDocsConfig(content, storyNames); for (const storyName of storyNames) { // Try CSF 3.0 format: export const StoryName: StoryObj = { args: {...}, argTypes: {...} } const csf3Pattern = new RegExp(`export\\s+const\\s+${storyName}[^=]*=[^{]*\\{`, 's'); const csf3Match = content.match(csf3Pattern); if (csf3Match) { const storyStartIndex = csf3Match.index + csf3Match[0].length - 1; const storyContent = extractBalancedBraces(content, storyStartIndex); if (storyContent) { // Extract args from story content const argsMatch = storyContent.match(/\bargs:\s*\{/); if (argsMatch) { const argsContent = extractBalancedBraces(storyContent, argsMatch.index + argsMatch[0].length - 1); if (argsContent) { parseArgsContent(argsContent, args); } } // Extract argTypes from story content const argTypesMatch = storyContent.match(/\bargTypes:\s*\{/); if (argTypesMatch) { const argTypesContent = extractBalancedBraces(storyContent, argTypesMatch.index + argTypesMatch[0].length - 1); if (argTypesContent) { parseArgTypes(argTypesContent, argTypes, content); } } if (Object.keys(args).length > 0 || Object.keys(argTypes).length > 0) { break; // Found a matching story } } } // Try CSF 2.0 format: StoryName.args = {...} const csf2ArgsPattern = new RegExp(`${storyName}\\.args\\s*=\\s*\\{`, 's'); const csf2ArgsMatch = content.match(csf2ArgsPattern); if (csf2ArgsMatch) { const argsContent = extractBalancedBraces(content, csf2ArgsMatch.index + csf2ArgsMatch[0].length - 1); if (argsContent) { parseArgsContent(argsContent, args); } } // Try CSF 2.0 argTypes: StoryName.argTypes = {...} const csf2ArgTypesPattern = new RegExp(`${storyName}\\.argTypes\\s*=\\s*\\{`, 's'); const csf2ArgTypesMatch = content.match(csf2ArgTypesPattern); if (csf2ArgTypesMatch) { const argTypesContent = extractBalancedBraces(content, csf2ArgTypesMatch.index + csf2ArgTypesMatch[0].length - 1); if (argTypesContent) { parseArgTypes(argTypesContent, argTypes, content); } } if (Object.keys(args).length > 0 || Object.keys(argTypes).length > 0) { break; // Found a matching story } } // Generate controls from args first, then add any argTypes-only props const controls = []; const processedProps = new Set(); // First pass: props that have default values in args for (const [key, value] of Object.entries(args)) { processedProps.add(key); const label = propNameToLabel(key); const argType = argTypes[key] || {}; if (argType.type) { // Use argTypes override (select, radio with options) controls.push({ name: key, label, type: argType.type, options: argType.options, description: argType.description }); } else if (typeof value === 'boolean') { controls.push({ name: key, label, type: 'boolean', description: argType.description }); } else if (typeof value === 'string') { controls.push({ name: key, label, type: 'text', description: argType.description }); } else if (typeof value === 'number') { controls.push({ name: key, label, type: 'number', description: argType.description }); } } // Second pass: props defined only in argTypes (no explicit value in args) // Add controls for these, but don't set default values on the component // (setting defaults like open: false or status: 'error' breaks component behavior) for (const [key, argType] of Object.entries(argTypes)) { if (processedProps.has(key)) continue; if (!argType.type) continue; // Skip if no control type defined const label = propNameToLabel(key); // Don't add to args - let the component use its own defaults controls.push({ name: key, label, type: argType.type, options: argType.options, description: argType.description }); } return { args, argTypes, controls, sampleChildren, sampleChildrenStyle, gallery, staticProps, liveExample, examples, renderComponent, triggerProp, onHideProp }; } /** * Generate MDX content for a component */ function generateMDX(component, storyContent) { const { componentName, description, relativePath, category, sourceConfig, resolvedImportPath, isDefaultExport } = component; const { args, argTypes, controls, sampleChildren, sampleChildrenStyle, gallery, staticProps, liveExample, examples, renderComponent, triggerProp, onHideProp } = extractArgsAndControls(storyContent, componentName); // Merge staticProps into args for complex values (arrays, objects) that can't be parsed from inline args const mergedArgs = { ...args, ...staticProps }; // Format JSON: unquote property names but keep double quotes for string values // This avoids issues with single quotes in strings breaking MDX parsing const controlsJson = JSON.stringify(controls, null, 2) .replace(/"(\w+)":/g, '$1:'); const propsJson = JSON.stringify(mergedArgs, null, 2) .replace(/"(\w+)":/g, '$1:'); // Format sampleChildren if present (from story's parameters.docs.sampleChildren) const sampleChildrenJson = sampleChildren ? JSON.stringify(sampleChildren) : null; // Format sampleChildrenStyle if present (from story's parameters.docs.sampleChildrenStyle) const sampleChildrenStyleJson = sampleChildrenStyle ? JSON.stringify(sampleChildrenStyle).replace(/"(\w+)":/g, '$1:') : null; // Format gallery config if present const hasGallery = gallery && gallery.sizes && gallery.styles; // Extract children for proper JSX rendering const childrenValue = mergedArgs.children; const liveExampleProps = Object.entries(mergedArgs) .filter(([key]) => key !== 'children') .map(([key, value]) => { if (typeof value === 'string') return `${key}="${value}"`; if (typeof value === 'boolean') return value ? key : null; return `${key}={${JSON.stringify(value)}}`; }) .filter(Boolean) .join('\n '); // Generate props table with descriptions from argTypes const propsTable = Object.entries(mergedArgs).map(([key, value]) => { const type = typeof value === 'boolean' ? 'boolean' : typeof value === 'string' ? 'string' : typeof value === 'number' ? 'number' : 'any'; const desc = argTypes[key]?.description || '-'; return `| \`${key}\` | \`${type}\` | \`${JSON.stringify(value)}\` | ${desc} |`; }).join('\n'); // Calculate relative import path based on category depth const importDepth = category.includes('/') ? 4 : 3; const wrapperImportPrefix = '../'.repeat(importDepth); // Use resolved import path if available, otherwise fall back to source config const componentImportPath = resolvedImportPath || sourceConfig.importPrefix; // Determine component description based on source const defaultDesc = sourceConfig.category === 'ui' ? `The ${componentName} component from Superset's UI library.` : `The ${componentName} component from Superset.`; return `--- title: ${componentName} sidebar_label: ${componentName} --- import { StoryWithControls${hasGallery ? ', ComponentGallery' : ''} } from '${wrapperImportPrefix}src/components/StorybookWrapper'; # ${componentName} ${description || defaultDesc} ${hasGallery ? ` ## All Variants ` : ''} ## Live Example ## Try It Edit the code below to experiment with the component: \`\`\`tsx live ${liveExample || `function Demo() { return ( <${componentName} ${liveExampleProps || '// Add props here'} ${childrenValue ? `> ${childrenValue} ` : '/>'} ); }`} \`\`\` ${examples && examples.length > 0 ? examples.map(ex => ` ## ${ex.title} \`\`\`tsx live ${ex.code} \`\`\` `).join('') : ''} ${Object.keys(args).length > 0 ? `## Props | Prop | Type | Default | Description | |------|------|---------|-------------| ${propsTable}` : ''} ## Import \`\`\`tsx ${isDefaultExport ? `import ${componentName} from '${componentImportPath}';` : `import { ${componentName} } from '${componentImportPath}';`} \`\`\` --- :::tip[Improve this page] This documentation is auto-generated from the component's Storybook story. Help improve it by [editing the story file](https://github.com/apache/superset/edit/master/${relativePath}). ::: `; } /** * Category display names for sidebar */ const CATEGORY_LABELS = { ui: { title: 'Core Components', sidebarLabel: 'Core Components', description: 'Buttons, inputs, modals, selects, and other fundamental UI elements.' }, 'design-system': { title: 'Layout Components', sidebarLabel: 'Layout Components', description: 'Grid, Layout, Table, Flex, Space, and container components for page structure.' }, }; /** * Generate category index page */ function generateCategoryIndex(category, components) { const labels = CATEGORY_LABELS[category] || { title: category.charAt(0).toUpperCase() + category.slice(1).replace(/-/g, ' '), sidebarLabel: category.charAt(0).toUpperCase() + category.slice(1).replace(/-/g, ' '), }; const componentList = components .sort((a, b) => a.componentName.localeCompare(b.componentName)) .map(c => `- [${c.componentName}](./${c.componentName.toLowerCase()})`) .join('\n'); return `--- title: ${labels.title} sidebar_label: ${labels.sidebarLabel} sidebar_position: 1 --- # ${labels.title} ${components.length} components available in this category. ## Components ${componentList} `; } /** * Generate main overview page */ function generateOverviewIndex(categories) { const categoryList = Object.entries(categories) .filter(([, components]) => components.length > 0) .map(([cat, components]) => { const labels = CATEGORY_LABELS[cat] || { title: cat.charAt(0).toUpperCase() + cat.slice(1).replace(/-/g, ' '), }; const desc = labels.description ? ` ${labels.description}` : ''; return `### [${labels.title}](./${cat}/)\n${components.length} components —${desc}\n`; }) .join('\n'); const totalComponents = Object.values(categories).reduce((sum, c) => sum + c.length, 0); return `--- title: UI Components Overview sidebar_label: Overview sidebar_position: 0 --- # Superset Design System A design system is a complete set of standards intended to manage design at scale using reusable components and patterns. The Superset Design System uses [Atomic Design](https://bradfrost.com/blog/post/atomic-web-design/) principles with adapted terminology: | Atomic Design | Atoms | Molecules | Organisms | Templates | Pages / Screens | |---|:---:|:---:|:---:|:---:|:---:| | **Superset Design** | Foundations | Components | Patterns | Templates | Features | Atoms = Foundations, Molecules = Components, Organisms = Patterns, Templates = Templates, Pages / Screens = Features --- ## Component Library Interactive documentation for Superset's UI component library. **${totalComponents} components** documented across ${Object.keys(categories).filter(k => categories[k].length > 0).length} categories. ${categoryList} ## Usage All components are exported from \`@superset-ui/core/components\`: \`\`\`tsx import { Button, Modal, Select } from '@superset-ui/core/components'; \`\`\` ## Contributing This documentation is auto-generated from Storybook stories. To add or update component documentation: 1. Create or update the component's \`.stories.tsx\` file 2. Add a descriptive \`title\` and \`description\` in the story meta 3. Export an interactive story with \`args\` for configurable props 4. Run \`yarn generate:superset-components\` in the \`docs/\` directory :::info Work in Progress This component library is actively being documented. See the [Components TODO](./TODO) page for a list of components awaiting documentation. ::: --- *Auto-generated from Storybook stories in the [Design System/Introduction](https://github.com/apache/superset/blob/master/superset-frontend/packages/superset-ui-core/src/components/DesignSystem.stories.tsx) story.* `; } /** * Generate TODO.md tracking skipped components */ function generateTodoMd(skippedFiles) { const disabledSources = SOURCES.filter(s => !s.enabled); const grouped = {}; for (const file of skippedFiles) { const source = disabledSources.find(s => file.includes(s.path)); const sourceName = source ? source.name : 'unknown'; if (!grouped[sourceName]) grouped[sourceName] = []; grouped[sourceName].push(file); } const sections = Object.entries(grouped) .map(([source, files]) => { const fileList = files.map(f => `- [ ] \`${path.relative(ROOT_DIR, f)}\``).join('\n'); return `### ${source}\n\n${files.length} components\n\n${fileList}`; }) .join('\n\n'); return `--- title: Components TODO sidebar_class_name: hidden --- # Components TODO These components were found but not yet supported for documentation generation. Future phases will add support for these sources. ## Summary - **Total skipped:** ${skippedFiles.length} story files - **Reason:** Import path resolution not yet implemented ## Skipped by Source ${sections} ## How to Add Support 1. Determine the correct import path for the source 2. Update \`generate-superset-components.mjs\` to handle the source 3. Add source to \`SUPPORTED_SOURCES\` array 4. Re-run the generator --- *Auto-generated by generate-superset-components.mjs* `; } /** * Main function */ async function main() { console.log('Generating Superset Components documentation...\n'); // Find enabled story files const enabledFiles = findEnabledStoryFiles(); console.log(`Found ${enabledFiles.length} story files from enabled sources\n`); // Find disabled story files (for tracking) const disabledFiles = findDisabledStoryFiles(); console.log(`Found ${disabledFiles.length} story files from disabled sources (tracking only)\n`); // Parse enabled files const components = []; for (const { file, source } of enabledFiles) { const parsed = parseStoryFile(file, source); if (parsed && parsed.componentName) { components.push(parsed); } } console.log(`Parsed ${components.length} components\n`); // Group by category const categories = {}; for (const component of components) { if (!categories[component.category]) { categories[component.category] = []; } categories[component.category].push(component); } // Ensure output directory exists if (!fs.existsSync(OUTPUT_DIR)) { fs.mkdirSync(OUTPUT_DIR, { recursive: true }); } // Generate MDX files by category let generatedCount = 0; for (const [category, categoryComponents] of Object.entries(categories)) { const categoryDir = path.join(OUTPUT_DIR, category); if (!fs.existsSync(categoryDir)) { fs.mkdirSync(categoryDir, { recursive: true }); } // Generate component pages for (const component of categoryComponents) { const storyContent = fs.readFileSync(component.filePath, 'utf-8'); const mdxContent = generateMDX(component, storyContent); const outputPath = path.join(categoryDir, `${component.componentName.toLowerCase()}.mdx`); fs.writeFileSync(outputPath, mdxContent); console.log(` ✓ ${category}/${component.componentName}`); generatedCount++; } // Generate category index const indexContent = generateCategoryIndex(category, categoryComponents); const indexPath = path.join(categoryDir, 'index.mdx'); fs.writeFileSync(indexPath, indexContent); console.log(` ✓ ${category}/index`); } // Generate main overview const overviewContent = generateOverviewIndex(categories); const overviewPath = path.join(OUTPUT_DIR, 'index.mdx'); fs.writeFileSync(overviewPath, overviewContent); console.log(` ✓ index (overview)`); // Generate TODO.md const todoContent = generateTodoMd(disabledFiles); const todoPath = path.join(OUTPUT_DIR, 'TODO.md'); fs.writeFileSync(todoPath, todoContent); console.log(` ✓ TODO.md`); console.log(`\nDone! Generated ${generatedCount} component pages.`); console.log(`Tracked ${disabledFiles.length} components for future implementation.`); } main().catch(console.error);