/**
* 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}
${componentName}>` : '/>'}
);
}`}
\`\`\`
${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 |
---
## 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);