feat(docs): add filterable UI Components table and improve build performance (#38253)

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Evan Rusackas
2026-03-04 08:32:25 -05:00
committed by GitHub
parent 983b633972
commit ef4b1d674b
21 changed files with 1597 additions and 833 deletions

View File

@@ -460,6 +460,9 @@ function generateDatabaseMDX(name, db) {
.replace(/\\/g, '\\\\')
.replace(/"/g, '\\"');
// Inline the database data directly to avoid importing the full databases.json
const inlineData = JSON.stringify(db);
return `---
title: ${name}
sidebar_label: ${name}
@@ -487,9 +490,10 @@ under the License.
*/}
import { DatabasePage } from '@site/src/components/databases';
import databaseData from '@site/src/data/databases.json';
<DatabasePage name="${name}" database={databaseData.databases["${name}"]} />
export const databaseInfo = ${inlineData};
<DatabasePage name="${name}" database={databaseInfo} />
`;
}

View File

@@ -1,676 +0,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.
*/
/**
* This script scans for Storybook stories in superset-core/src and generates
* MDX documentation pages for the developer portal. All components in
* superset-core are considered extension-compatible by virtue of their location.
*
* Usage: node scripts/generate-extension-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/extensions/components'
);
const TYPES_OUTPUT_DIR = path.join(DOCS_DIR, 'src/types/apache-superset-core');
const TYPES_OUTPUT_PATH = path.join(TYPES_OUTPUT_DIR, 'index.d.ts');
const SUPERSET_CORE_DIR = path.join(
ROOT_DIR,
'superset-frontend/packages/superset-core'
);
/**
* Find all story files in the superset-core package
*/
async function findStoryFiles() {
const files = [];
// Use fs to recursively find files since glob might not be available
function walkDir(dir) {
if (!fs.existsSync(dir)) return;
const entries = fs.readdirSync(dir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
walkDir(fullPath);
} else if (entry.name.endsWith('.stories.tsx')) {
files.push(fullPath);
}
}
}
walkDir(path.join(SUPERSET_CORE_DIR, 'src'));
return files;
}
/**
* Parse a story file and extract metadata
*
* All stories in superset-core are considered extension-compatible
* by virtue of their location - no tag needed.
*/
function parseStoryFile(filePath) {
const content = fs.readFileSync(filePath, 'utf-8');
// Extract component name from title
const titleMatch = content.match(/title:\s*['"]([^'"]+)['"]/);
const title = titleMatch ? titleMatch[1] : null;
// Extract component name (last part of title path)
const componentName = title ? title.split('/').pop() : null;
// Extract description from parameters
// Handle concatenated strings like: 'part1 ' + 'part2'
let description = '';
// First try to find the description block
const descBlockMatch = content.match(
/description:\s*{\s*component:\s*([\s\S]*?)\s*},?\s*}/
);
if (descBlockMatch) {
const descBlock = descBlockMatch[1];
// Extract all string literals and concatenate them
const stringParts = [];
const stringMatches = descBlock.matchAll(/['"]([^'"]*)['"]/g);
for (const match of stringMatches) {
stringParts.push(match[1]);
}
description = stringParts.join('').trim();
}
// Extract package info
const packageMatch = content.match(/package:\s*['"]([^'"]+)['"]/);
const packageName = packageMatch ? packageMatch[1] : '@apache-superset/core/ui';
// Extract import path - handle double-quoted strings containing single quotes
// Match: importPath: "import { Alert } from '@apache-superset/core';"
const importMatchDouble = content.match(/importPath:\s*"([^"]+)"/);
const importMatchSingle = content.match(/importPath:\s*'([^']+)'/);
let importPath = `import { ${componentName} } from '${packageName}';`;
if (importMatchDouble) {
importPath = importMatchDouble[1];
} else if (importMatchSingle) {
importPath = importMatchSingle[1];
}
// Get the directory containing the story to find the component
const storyDir = path.dirname(filePath);
const componentFile = path.join(storyDir, 'index.tsx');
const hasComponentFile = fs.existsSync(componentFile);
// Try to extract props interface from component file (for future use)
if (hasComponentFile) {
// Read component file - props extraction reserved for future enhancement
// const componentContent = fs.readFileSync(componentFile, 'utf-8');
}
// Extract story exports (named exports that aren't the default)
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]);
}
}
return {
filePath,
title,
componentName,
description,
packageName,
importPath,
storyExports,
hasComponentFile,
relativePath: path.relative(ROOT_DIR, filePath),
};
}
/**
* Extract argTypes/args from story content for generating controls
*/
function extractArgsAndControls(content, componentName, storyContent) {
// Look for InteractiveX.args pattern - handle multi-line objects
const argsMatch = content.match(
new RegExp(`Interactive${componentName}\\.args\\s*=\\s*\\{([\\s\\S]*?)\\};`, 's')
);
// Look for argTypes
const argTypesMatch = content.match(
new RegExp(`Interactive${componentName}\\.argTypes\\s*=\\s*\\{([\\s\\S]*?)\\};`, 's')
);
const args = {};
const controls = [];
const propDescriptions = {};
if (argsMatch) {
// Parse args - handle strings, booleans, numbers
// Note: Using simple regex without escape handling for security (avoids ReDoS)
// This is sufficient for Storybook args which rarely contain escaped quotes
const argsContent = argsMatch[1];
const argLines = argsContent.matchAll(/(\w+):\s*(['"]([^'"]*)['"']|true|false|\d+)/g);
for (const match of argLines) {
const key = match[1];
let value = match[2];
// Convert string booleans
if (value === 'true') value = true;
else if (value === 'false') value = false;
else if (!isNaN(Number(value))) value = Number(value);
else if (match[3] !== undefined) value = match[3]; // Use captured string content
args[key] = value;
}
}
if (argTypesMatch) {
const argTypesContent = argTypesMatch[1];
// Match each top-level property in argTypes
// Pattern: propertyName: { ... }, (with balanced braces)
const propPattern = /(\w+):\s*\{([^{}]*(?:\{[^{}]*\}[^{}]*)*)\}/g;
let propMatch;
while ((propMatch = propPattern.exec(argTypesContent)) !== null) {
const name = propMatch[1];
const propContent = propMatch[2];
// Extract description if present
const descMatch = propContent.match(/description:\s*['"]([^'"]+)['"]/);
if (descMatch) {
propDescriptions[name] = descMatch[1];
}
// Skip if it's an action (not a control)
if (propContent.includes('action:')) continue;
// Extract label for display
const label = name.charAt(0).toUpperCase() + name.slice(1).replace(/([A-Z])/g, ' $1');
// Check for select control
if (propContent.includes("type: 'select'") || propContent.includes('type: "select"')) {
// Look for options - could be inline array or variable reference
const inlineOptionsMatch = propContent.match(/options:\s*\[([^\]]+)\]/);
const varOptionsMatch = propContent.match(/options:\s*(\w+)/);
let options = [];
if (inlineOptionsMatch) {
options = [...inlineOptionsMatch[1].matchAll(/['"]([^'"]+)['"]/g)].map(m => m[1]);
} else if (varOptionsMatch && storyContent) {
// Look up the variable
const varName = varOptionsMatch[1];
const varDefMatch = storyContent.match(
new RegExp(`const\\s+${varName}[^=]*=\\s*\\[([^\\]]+)\\]`)
);
if (varDefMatch) {
options = [...varDefMatch[1].matchAll(/['"]([^'"]+)['"]/g)].map(m => m[1]);
}
}
if (options.length > 0) {
controls.push({ name, label, type: 'select', options });
}
}
// Check for boolean control
else if (propContent.includes("type: 'boolean'") || propContent.includes('type: "boolean"')) {
controls.push({ name, label, type: 'boolean' });
}
// Check for text/string control (default for props in args without explicit control)
else if (args[name] !== undefined && typeof args[name] === 'string') {
controls.push({ name, label, type: 'text' });
}
}
}
// Add text controls for string args that don't have explicit argTypes
for (const [key, value] of Object.entries(args)) {
if (typeof value === 'string' && !controls.find(c => c.name === key)) {
const label = key.charAt(0).toUpperCase() + key.slice(1).replace(/([A-Z])/g, ' $1');
controls.push({ name: key, label, type: 'text' });
}
}
return { args, controls, propDescriptions };
}
/**
* Generate MDX content for a component
*/
function generateMDX(component, storyContent) {
const { componentName, description, importPath, packageName, relativePath } =
component;
// Extract args, controls, and descriptions from the story
const { args, controls, propDescriptions } = extractArgsAndControls(storyContent, componentName, storyContent);
// Generate the controls array for StoryWithControls
const controlsJson = JSON.stringify(controls, null, 2)
.replace(/"(\w+)":/g, '$1:') // Remove quotes from keys
.replace(/"/g, "'"); // Use single quotes for strings
// Generate default props
const propsJson = JSON.stringify(args, null, 2)
.replace(/"(\w+)":/g, '$1:')
.replace(/"/g, "'");
// Generate a realistic live code example from the actual args
const liveExampleProps = Object.entries(args)
.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(args).map(([key, value]) => {
const type = typeof value === 'boolean' ? 'boolean' : typeof value === 'string' ? 'string' : 'any';
const desc = propDescriptions[key] || key.charAt(0).toUpperCase() + key.slice(1).replace(/([A-Z])/g, ' $1');
return `| \`${key}\` | \`${type}\` | \`${JSON.stringify(value)}\` | ${desc} |`;
}).join('\n');
// Generate usage example props (simplified for readability)
const usageExampleProps = Object.entries(args)
.slice(0, 3) // Show first 3 props for brevity
.map(([key, value]) => {
if (typeof value === 'string') return `${key}="${value}"`;
if (typeof value === 'boolean') return value ? key : `${key}={false}`;
return `${key}={${JSON.stringify(value)}}`;
})
.join('\n ');
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 } from '../../../src/components/StorybookWrapper';
import { ${componentName} } from '@apache-superset/core/ui';
# ${componentName}
${description || `The ${componentName} component from the Superset extension API.`}
## Live Example
<StoryWithControls
component={${componentName}}
props={${propsJson}}
controls={${controlsJson}}
/>
## Try It
Edit the code below to experiment with the component:
\`\`\`tsx live
function Demo() {
return (
<${componentName}
${liveExampleProps}
/>
);
}
\`\`\`
## Props
| Prop | Type | Default | Description |
|------|------|---------|-------------|
${propsTable}
## Usage in Extensions
This component is available in the \`${packageName}\` package, which is automatically available to Superset extensions.
\`\`\`tsx
${importPath}
function MyExtension() {
return (
<${componentName}
${usageExampleProps}
/>
);
}
\`\`\`
## Source Links
- [Story file](https://github.com/apache/superset/blob/master/${relativePath})
- [Component source](https://github.com/apache/superset/blob/master/${relativePath.replace(/\/[^/]+\.stories\.tsx$/, '/index.tsx')})
---
*This page was auto-generated from the component's Storybook story.*
`;
}
/**
* Generate index page for extension components
*/
function generateIndexMDX(components) {
const componentList = components
.map(c => `- [${c.componentName}](./${c.componentName.toLowerCase()})`)
.join('\n');
return `---
title: Extension Components
sidebar_label: Overview
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.
-->
# Extension Components
These UI components are available to Superset extension developers through the \`@apache-superset/core/ui\` package. They provide a consistent look and feel with the rest of Superset and are designed to be used in extension panels, views, and other UI elements.
## Available Components
${componentList}
## Usage
All components are exported from the \`@apache-superset/core/ui\` package:
\`\`\`tsx
import { Alert } from '@apache-superset/core/ui';
export function MyExtensionPanel() {
return (
<Alert type="info">
Welcome to my extension!
</Alert>
);
}
\`\`\`
## Adding New Components
Components in \`@apache-superset/core/ui\` are automatically documented here. To add a new extension component:
1. Add the component to \`superset-frontend/packages/superset-core/src/ui/components/\`
2. Export it from \`superset-frontend/packages/superset-core/src/ui/components/index.ts\`
3. Create a Storybook story with an \`Interactive\` export:
\`\`\`tsx
export default {
title: 'Extension Components/MyComponent',
component: MyComponent,
parameters: {
docs: {
description: {
component: 'Description of the component...',
},
},
},
};
export const InteractiveMyComponent = (args) => <MyComponent {...args} />;
InteractiveMyComponent.args = {
variant: 'primary',
disabled: false,
};
InteractiveMyComponent.argTypes = {
variant: {
control: { type: 'select' },
options: ['primary', 'secondary'],
},
disabled: {
control: { type: 'boolean' },
},
};
\`\`\`
4. Run \`yarn start\` in \`docs/\` - the page generates automatically!
## Interactive Documentation
For interactive examples with controls, visit the [Storybook](/storybook/?path=/docs/extension-components--docs).
`;
}
/**
* Extract type exports from a component file
*/
function extractComponentTypes(componentPath) {
if (!fs.existsSync(componentPath)) {
return null;
}
const content = fs.readFileSync(componentPath, 'utf-8');
const types = [];
// Find all "export type X = ..." declarations
const typeMatches = content.matchAll(/export\s+type\s+(\w+)\s*=\s*([^;]+);/g);
for (const match of typeMatches) {
types.push({
name: match[1],
definition: match[2].trim(),
});
}
// Find all "export const X = ..." declarations (components)
const constMatches = content.matchAll(/export\s+const\s+(\w+)\s*[=:]/g);
const components = [];
for (const match of constMatches) {
components.push(match[1]);
}
return { types, components };
}
/**
* Generate the type declarations file content
*/
function generateTypeDeclarations(componentInfos) {
const imports = new Set();
const typeDeclarations = [];
const componentDeclarations = [];
for (const info of componentInfos) {
const componentDir = path.dirname(info.filePath);
const componentFile = path.join(componentDir, 'index.tsx');
const extracted = extractComponentTypes(componentFile);
if (!extracted) continue;
// Check if types reference antd or react
for (const type of extracted.types) {
if (type.definition.includes('AntdAlertProps') || type.definition.includes('AlertProps')) {
imports.add("import type { AlertProps as AntdAlertProps } from 'antd/es/alert';");
}
if (type.definition.includes('PropsWithChildren') || type.definition.includes('FC')) {
imports.add("import type { PropsWithChildren, FC } from 'react';");
}
// Add the type declaration
typeDeclarations.push(` export type ${type.name} = ${type.definition};`);
}
// Add component declarations
for (const comp of extracted.components) {
const propsType = `${comp}Props`;
const hasPropsType = extracted.types.some(t => t.name === propsType);
if (hasPropsType) {
componentDeclarations.push(` export const ${comp}: FC<${propsType}>;`);
} else {
componentDeclarations.push(` export const ${comp}: FC<Record<string, unknown>>;`);
}
}
}
// Remove 'export' prefix for direct exports (not in declare module)
const cleanedTypes = typeDeclarations.map(t => t.replace(/^ {2}export /, 'export '));
const cleanedComponents = componentDeclarations.map(c => c.replace(/^ {2}export /, 'export '));
return `/**
* 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.
*/
/**
* Type declarations for @apache-superset/core/ui
*
* AUTO-GENERATED by scripts/generate-extension-components.mjs
* Do not edit manually - regenerate by running: yarn generate:extension-components
*/
${Array.from(imports).join('\n')}
${cleanedTypes.join('\n')}
${cleanedComponents.join('\n')}
`;
}
/**
* Main function
*/
async function main() {
console.log('Scanning for extension-compatible stories...\n');
// Find all story files
const storyFiles = await findStoryFiles();
console.log(`Found ${storyFiles.length} story files in superset-core\n`);
// Parse each story file
const components = [];
for (const file of storyFiles) {
const parsed = parseStoryFile(file);
if (parsed) {
components.push(parsed);
console.log(`${parsed.componentName} (${parsed.relativePath})`);
}
}
if (components.length === 0) {
console.log(
'\nNo extension-compatible components found. Make sure stories have:'
);
console.log(" tags: ['extension-compatible']");
return;
}
console.log(`\nFound ${components.length} extension-compatible components\n`);
// Ensure output directory exists
if (!fs.existsSync(OUTPUT_DIR)) {
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
console.log(`Created directory: ${OUTPUT_DIR}\n`);
}
// Generate MDX files
for (const component of components) {
// Read the story content for extracting args/controls
const storyContent = fs.readFileSync(component.filePath, 'utf-8');
const mdxContent = generateMDX(component, storyContent);
const outputPath = path.join(
OUTPUT_DIR,
`${component.componentName.toLowerCase()}.mdx`
);
fs.writeFileSync(outputPath, mdxContent);
console.log(` Generated: ${path.relative(DOCS_DIR, outputPath)}`);
}
// Generate index page
const indexContent = generateIndexMDX(components);
const indexPath = path.join(OUTPUT_DIR, 'index.mdx');
fs.writeFileSync(indexPath, indexContent);
console.log(` Generated: ${path.relative(DOCS_DIR, indexPath)}`);
// Generate type declarations
if (!fs.existsSync(TYPES_OUTPUT_DIR)) {
fs.mkdirSync(TYPES_OUTPUT_DIR, { recursive: true });
}
const typesContent = generateTypeDeclarations(components);
fs.writeFileSync(TYPES_OUTPUT_PATH, typesContent);
console.log(` Generated: ${path.relative(DOCS_DIR, TYPES_OUTPUT_PATH)}`);
console.log('\nDone! Extension component documentation generated.');
console.log(
`\nGenerated ${components.length + 2} files (${components.length + 1} MDX + 1 type declaration)`
);
}
main().catch(console.error);

View File

@@ -0,0 +1,307 @@
/**
* 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.
*/
/**
* Smart generator wrapper: only runs generators whose input files have changed.
*
* Computes a hash of each generator's input files (stories, engine specs,
* openapi.json, and the generator scripts themselves). Compares against a
* stored cache. Skips generators whose inputs and outputs are unchanged.
*
* Usage:
* node scripts/generate-if-changed.mjs # smart mode (default)
* node scripts/generate-if-changed.mjs --force # force regenerate all
*/
import { createHash } from 'crypto';
import { execSync, spawn } from 'child_process';
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const DOCS_DIR = path.resolve(__dirname, '..');
const ROOT_DIR = path.resolve(DOCS_DIR, '..');
const CACHE_FILE = path.join(DOCS_DIR, '.docusaurus', 'generator-hashes.json');
const FORCE = process.argv.includes('--force');
// Ensure local node_modules/.bin is on PATH (needed for docusaurus CLI)
const localBin = path.join(DOCS_DIR, 'node_modules', '.bin');
process.env.PATH = `${localBin}${path.delimiter}${process.env.PATH}`;
// ---------------------------------------------------------------------------
// Generator definitions
// ---------------------------------------------------------------------------
const GENERATORS = [
{
name: 'superset-components',
command: 'node scripts/generate-superset-components.mjs',
inputs: [
{
type: 'glob',
base: path.join(ROOT_DIR, 'superset-frontend/packages/superset-ui-core/src/components'),
pattern: '**/*.stories.tsx',
},
{
type: 'glob',
base: path.join(ROOT_DIR, 'superset-frontend/packages/superset-core/src'),
pattern: '**/*.stories.tsx',
},
{ type: 'file', path: path.join(DOCS_DIR, 'scripts/generate-superset-components.mjs') },
{ type: 'file', path: path.join(DOCS_DIR, 'src/components/StorybookWrapper.jsx') },
],
outputs: [
path.join(DOCS_DIR, 'developer_docs/components/index.mdx'),
path.join(DOCS_DIR, 'static/data/components.json'),
path.join(DOCS_DIR, 'src/types/apache-superset-core/index.d.ts'),
],
},
{
name: 'database-docs',
command: 'node scripts/generate-database-docs.mjs',
inputs: [
{
type: 'glob',
base: path.join(ROOT_DIR, 'superset/db_engine_specs'),
pattern: '**/*.py',
},
{ type: 'file', path: path.join(DOCS_DIR, 'scripts/generate-database-docs.mjs') },
],
outputs: [
path.join(DOCS_DIR, 'src/data/databases.json'),
path.join(DOCS_DIR, 'docs/databases/supported'),
],
},
{
name: 'api-docs',
command:
'python3 scripts/fix-openapi-spec.py && docusaurus gen-api-docs superset && node scripts/convert-api-sidebar.mjs && node scripts/generate-api-index.mjs && node scripts/generate-api-tag-pages.mjs',
inputs: [
{ type: 'file', path: path.join(DOCS_DIR, 'static/resources/openapi.json') },
{ type: 'file', path: path.join(DOCS_DIR, 'scripts/fix-openapi-spec.py') },
{ type: 'file', path: path.join(DOCS_DIR, 'scripts/convert-api-sidebar.mjs') },
{ type: 'file', path: path.join(DOCS_DIR, 'scripts/generate-api-index.mjs') },
{ type: 'file', path: path.join(DOCS_DIR, 'scripts/generate-api-tag-pages.mjs') },
],
outputs: [
path.join(DOCS_DIR, 'docs/api.mdx'),
],
},
];
// ---------------------------------------------------------------------------
// Hashing utilities
// ---------------------------------------------------------------------------
function walkDir(dir, pattern) {
const results = [];
if (!fs.existsSync(dir)) return results;
const regex = globToRegex(pattern);
function walk(currentDir) {
for (const entry of fs.readdirSync(currentDir, { withFileTypes: true })) {
const fullPath = path.join(currentDir, entry.name);
if (entry.isDirectory()) {
if (entry.name === 'node_modules' || entry.name === '__pycache__') continue;
walk(fullPath);
} else {
// Normalize to forward slashes so glob patterns work on all platforms
const relativePath = path.relative(dir, fullPath).split(path.sep).join('/');
if (regex.test(relativePath)) {
results.push(fullPath);
}
}
}
}
walk(dir);
return results.sort();
}
function globToRegex(pattern) {
// Simple glob-to-regex: ** matches any path, * matches anything except /
const escaped = pattern
.replace(/[.+^${}()|[\]\\]/g, '\\$&')
.replace(/\*\*/g, '<<<GLOBSTAR>>>')
.replace(/\*/g, '[^/]*')
.replace(/<<<GLOBSTAR>>>/g, '.*');
return new RegExp(`^${escaped}$`);
}
function hashFile(filePath) {
if (!fs.existsSync(filePath)) return 'missing';
const stat = fs.statSync(filePath);
// Use mtime + size for speed (avoids reading file contents)
return `${stat.mtimeMs}:${stat.size}`;
}
function computeInputHash(inputs) {
const hash = createHash('md5');
for (const input of inputs) {
if (input.type === 'file') {
hash.update(`file:${input.path}:${hashFile(input.path)}\n`);
} else if (input.type === 'glob') {
const files = walkDir(input.base, input.pattern);
hash.update(`glob:${input.base}:${input.pattern}:count=${files.length}\n`);
for (const file of files) {
hash.update(` ${path.relative(input.base, file)}:${hashFile(file)}\n`);
}
}
}
return hash.digest('hex');
}
function outputsExist(outputs) {
return outputs.every((p) => fs.existsSync(p));
}
// ---------------------------------------------------------------------------
// Cache management
// ---------------------------------------------------------------------------
function loadCache() {
try {
if (fs.existsSync(CACHE_FILE)) {
return JSON.parse(fs.readFileSync(CACHE_FILE, 'utf8'));
}
} catch {
// Corrupted cache — start fresh
}
return {};
}
function saveCache(cache) {
const dir = path.dirname(CACHE_FILE);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
fs.writeFileSync(CACHE_FILE, JSON.stringify(cache, null, 2));
}
// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
async function main() {
const cache = loadCache();
const updatedCache = { ...cache };
let skipped = 0;
let ran = 0;
// First pass: determine which generators need to run
// Run independent generators (superset-components, database-docs) in
// parallel, then api-docs sequentially (it depends on docusaurus CLI
// being available, not on other generators).
const independent = GENERATORS.filter((g) => g.name !== 'api-docs');
const sequential = GENERATORS.filter((g) => g.name === 'api-docs');
// Check and run independent generators in parallel
const parallelPromises = independent.map((gen) => {
const currentHash = computeInputHash(gen.inputs);
const cachedHash = cache[gen.name];
const hasOutputs = outputsExist(gen.outputs);
if (!FORCE && currentHash === cachedHash && hasOutputs) {
console.log(`${gen.name} — no changes, skipping`);
skipped++;
return Promise.resolve();
}
const reason = FORCE
? 'forced'
: !hasOutputs
? 'output missing'
: 'inputs changed';
console.log(`${gen.name}${reason}, regenerating...`);
ran++;
return new Promise((resolve, reject) => {
const child = spawn('sh', ['-c', gen.command], {
cwd: DOCS_DIR,
stdio: 'inherit',
env: process.env,
});
child.on('close', (code) => {
if (code === 0) {
updatedCache[gen.name] = currentHash;
resolve();
} else {
console.error(`${gen.name} failed (exit ${code})`);
reject(new Error(`${gen.name} failed with exit code ${code}`));
}
});
child.on('error', (err) => {
console.error(`${gen.name} failed to start`);
reject(err);
});
});
});
await Promise.all(parallelPromises);
// Run sequential generators (api-docs)
for (const gen of sequential) {
const currentHash = computeInputHash(gen.inputs);
const cachedHash = cache[gen.name];
const hasOutputs = outputsExist(gen.outputs);
if (!FORCE && currentHash === cachedHash && hasOutputs) {
console.log(`${gen.name} — no changes, skipping`);
skipped++;
continue;
}
const reason = FORCE
? 'forced'
: !hasOutputs
? 'output missing'
: 'inputs changed';
console.log(`${gen.name}${reason}, regenerating...`);
ran++;
try {
execSync(gen.command, {
cwd: DOCS_DIR,
stdio: 'inherit',
timeout: 300_000,
});
updatedCache[gen.name] = currentHash;
} catch (err) {
console.error(`${gen.name} failed`);
throw err;
}
}
saveCache(updatedCache);
if (ran === 0) {
console.log(`\nAll ${skipped} generators up-to-date — nothing to do!\n`);
} else {
console.log(`\nDone: ${ran} regenerated, ${skipped} skipped.\n`);
}
}
console.log('Checking generators for changes...\n');
main().catch((err) => {
console.error(err);
process.exit(1);
});

View File

@@ -58,7 +58,10 @@ 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 OUTPUT_DIR = path.join(DOCS_DIR, 'developer_docs/components');
const JSON_OUTPUT_PATH = path.join(DOCS_DIR, 'static/data/components.json');
const TYPES_OUTPUT_DIR = path.join(DOCS_DIR, 'src/types/apache-superset-core');
const TYPES_OUTPUT_PATH = path.join(TYPES_OUTPUT_DIR, 'index.d.ts');
const FRONTEND_DIR = path.join(ROOT_DIR, 'superset-frontend');
// Source configurations with import paths and categories
@@ -146,6 +149,16 @@ const SOURCES = [
enabled: false, // Requires specific setup
skipComponents: new Set([]),
},
{
name: 'Extension Components',
path: 'packages/superset-core/src',
importPrefix: '@apache-superset/core/ui',
docImportPrefix: '@apache-superset/core/ui',
category: 'extension',
enabled: true,
extensionCompatible: true,
skipComponents: new Set([]),
},
];
// Category mapping from story title prefixes to output directories
@@ -156,7 +169,7 @@ const CATEGORY_MAP = {
'Legacy Chart Plugins/': 'legacy-charts',
'Core Packages/': 'core-packages',
'Others/': 'utilities',
'Extension Components/': 'extension', // Skip - handled by other script
'Extension Components/': 'extension',
'Superset App/': 'app',
};
@@ -334,6 +347,7 @@ function parseStoryFile(filePath, sourceConfig) {
sourceConfig,
resolvedImportPath,
isDefaultExport,
extensionCompatible: Boolean(sourceConfig.extensionCompatible),
};
}
@@ -1141,6 +1155,7 @@ Help improve it by [editing the story file](https://github.com/apache/superset/e
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.' },
extension: { title: 'Extension Components', sidebarLabel: 'Extension Components', description: 'Components available to extension developers via @apache-superset/core/ui.' },
};
/**
@@ -1194,20 +1209,7 @@ ${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);
function generateOverviewIndex() {
return `---
title: UI Components Overview
sidebar_label: Overview
@@ -1233,7 +1235,16 @@ sidebar_position: 0
under the License.
-->
# Superset Design System
import { ComponentIndex } from '@site/src/components/ui-components';
import componentData from '@site/static/data/components.json';
# UI Components
<ComponentIndex data={componentData} />
---
## Design System
A design system is a complete set of standards intended to manage design at scale using reusable components and patterns.
@@ -1245,14 +1256,6 @@ The Superset Design System uses [Atomic Design](https://bradfrost.com/blog/post/
<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\`:
@@ -1332,6 +1335,147 @@ ${sections}
`;
}
/**
* Build metadata for a component (for JSON output)
*/
function buildComponentMetadata(component, storyContent) {
const { componentName, description, category, sourceConfig, resolvedImportPath, extensionCompatible } = component;
const { args, controls, gallery, liveExample } = extractArgsAndControls(storyContent, componentName);
const labels = CATEGORY_LABELS[category] || {
title: category.charAt(0).toUpperCase() + category.slice(1).replace(/-/g, ' '),
};
return {
name: componentName,
category,
categoryLabel: labels.title || category,
description: description || '',
importPath: resolvedImportPath || sourceConfig.importPrefix,
package: sourceConfig.docImportPrefix,
extensionCompatible: Boolean(extensionCompatible),
propsCount: Object.keys(args).length,
controlsCount: controls.length,
hasGallery: Boolean(gallery && gallery.sizes && gallery.styles),
hasLiveExample: Boolean(liveExample),
docPath: `developer-docs/components/${category}/${componentName.toLowerCase()}`,
storyFile: component.relativePath,
};
}
/**
* Extract type and component export declarations from a component source file.
* Used to generate .d.ts type declarations for extension-compatible components.
*/
function extractComponentTypes(componentPath) {
if (!fs.existsSync(componentPath)) return null;
const content = fs.readFileSync(componentPath, 'utf-8');
const types = [];
// Match "export type Name = <definition>;" handling nested braces
// so object types like { a: string; b: number } are captured fully.
const typeRegex = /export\s+type\s+(\w+)\s*=\s*/g;
let typeMatch;
while ((typeMatch = typeRegex.exec(content)) !== null) {
const start = typeMatch.index + typeMatch[0].length;
let depth = 0;
let end = start;
for (let i = start; i < content.length; i++) {
const ch = content[i];
if (ch === '{' || ch === '<' || ch === '(') depth++;
else if (ch === '}' || ch === '>' || ch === ')') depth--;
else if (ch === ';' && depth === 0) {
end = i;
break;
}
}
const definition = content.slice(start, end).trim();
if (definition) {
types.push({ name: typeMatch[1], definition });
}
}
const components = [];
for (const match of content.matchAll(/export\s+const\s+(\w+)\s*[=:]/g)) {
components.push(match[1]);
}
return { types, components };
}
/**
* Generate TypeScript type declarations for extension-compatible components.
* Produces a .d.ts file that downstream consumers can reference.
*/
function generateExtensionTypeDeclarations(extensionComponents) {
const imports = new Set();
const typeDeclarations = [];
const componentDeclarations = [];
for (const comp of extensionComponents) {
const componentDir = path.dirname(comp.filePath);
const componentFile = path.join(componentDir, 'index.tsx');
const extracted = extractComponentTypes(componentFile);
if (!extracted) continue;
for (const type of extracted.types) {
if (type.definition.includes('AntdAlertProps') || type.definition.includes('AlertProps')) {
imports.add("import type { AlertProps as AntdAlertProps } from 'antd/es/alert';");
}
if (type.definition.includes('PropsWithChildren') || type.definition.includes('FC')) {
imports.add("import type { PropsWithChildren, FC } from 'react';");
}
typeDeclarations.push(`export type ${type.name} = ${type.definition};`);
}
for (const name of extracted.components) {
const propsType = `${name}Props`;
const hasPropsType = extracted.types.some(t => t.name === propsType);
componentDeclarations.push(
hasPropsType
? `export const ${name}: FC<${propsType}>;`
: `export const ${name}: FC<Record<string, unknown>>;`
);
}
}
// Always import FC when we have component declarations that reference it
if (componentDeclarations.length > 0) {
imports.add("import type { PropsWithChildren, FC } from 'react';");
}
return `/**
* 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.
*/
/**
* Type declarations for @apache-superset/core/ui
*
* AUTO-GENERATED by scripts/generate-superset-components.mjs
* Do not edit manually - regenerate by running: yarn generate:superset-components
*/
${Array.from(imports).join('\n')}
${typeDeclarations.join('\n')}
${componentDeclarations.join('\n')}
`;
}
/**
* Main function
*/
@@ -1396,8 +1540,53 @@ async function main() {
console.log(`${category}/index`);
}
// Build JSON metadata for all components
console.log('\nBuilding component metadata JSON...');
const componentMetadata = [];
for (const component of components) {
const storyContent = fs.readFileSync(component.filePath, 'utf-8');
componentMetadata.push(buildComponentMetadata(component, storyContent));
}
// Build statistics
const byCategory = {};
for (const comp of componentMetadata) {
if (!byCategory[comp.category]) byCategory[comp.category] = 0;
byCategory[comp.category]++;
}
const jsonData = {
generated: new Date().toISOString(),
statistics: {
totalComponents: componentMetadata.length,
byCategory,
extensionCompatible: componentMetadata.filter(c => c.extensionCompatible).length,
withGallery: componentMetadata.filter(c => c.hasGallery).length,
withLiveExample: componentMetadata.filter(c => c.hasLiveExample).length,
},
components: componentMetadata,
};
// Ensure data directory exists and write JSON
const jsonDir = path.dirname(JSON_OUTPUT_PATH);
if (!fs.existsSync(jsonDir)) {
fs.mkdirSync(jsonDir, { recursive: true });
}
fs.writeFileSync(JSON_OUTPUT_PATH, JSON.stringify(jsonData, null, 2));
console.log(` ✓ components.json (${componentMetadata.length} components)`);
// Generate type declarations for extension-compatible components
const extensionComponents = components.filter(c => c.extensionCompatible);
if (extensionComponents.length > 0) {
if (!fs.existsSync(TYPES_OUTPUT_DIR)) {
fs.mkdirSync(TYPES_OUTPUT_DIR, { recursive: true });
}
const typesContent = generateExtensionTypeDeclarations(extensionComponents);
fs.writeFileSync(TYPES_OUTPUT_PATH, typesContent);
console.log(` ✓ extension types (${extensionComponents.length} components)`);
}
// Generate main overview
const overviewContent = generateOverviewIndex(categories);
const overviewContent = generateOverviewIndex();
const overviewPath = path.join(OUTPUT_DIR, 'index.mdx');
fs.writeFileSync(overviewPath, overviewContent);
console.log(` ✓ index (overview)`);