mirror of
https://github.com/apache/superset.git
synced 2026-04-07 18:35:15 +00:00
1416 lines
50 KiB
JavaScript
1416 lines
50 KiB
JavaScript
/**
|
|
* 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}
|
|
---
|
|
|
|
<!--
|
|
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.
|
|
-->
|
|
|
|
import { StoryWithControls${hasGallery ? ', ComponentGallery' : ''} } from '${wrapperImportPrefix}src/components/StorybookWrapper';
|
|
|
|
# ${componentName}
|
|
|
|
${description || defaultDesc}
|
|
${hasGallery ? `
|
|
## All Variants
|
|
|
|
<ComponentGallery
|
|
component="${gallery.component || componentName}"
|
|
sizes={${JSON.stringify(gallery.sizes)}}
|
|
styles={${JSON.stringify(gallery.styles)}}
|
|
sizeProp="${gallery.sizeProp || 'size'}"
|
|
styleProp="${gallery.styleProp || 'variant'}"
|
|
/>
|
|
` : ''}
|
|
## Live Example
|
|
|
|
<StoryWithControls
|
|
component="${componentName}"${renderComponent ? `
|
|
renderComponent="${renderComponent}"` : ''}
|
|
props={${propsJson}}
|
|
controls={${controlsJson}}${sampleChildrenJson ? `
|
|
sampleChildren={${sampleChildrenJson}}` : ''}${sampleChildrenStyleJson ? `
|
|
sampleChildrenStyle={${sampleChildrenStyleJson}}` : ''}${triggerProp ? `
|
|
triggerProp="${triggerProp}"` : ''}${onHideProp ? `
|
|
onHideProp="${onHideProp}"` : ''}
|
|
/>
|
|
|
|
## 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
|
|
---
|
|
|
|
<!--
|
|
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.
|
|
-->
|
|
|
|
# ${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
|
|
---
|
|
|
|
<!--
|
|
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.
|
|
-->
|
|
|
|
# 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 |
|
|
|
|
<img src="/img/atomic-design.png" alt="Atoms = Foundations, Molecules = Components, Organisms = Patterns, Templates = Templates, Pages / Screens = Features" style={{maxWidth: '100%'}} />
|
|
|
|
---
|
|
|
|
## 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);
|