blob: c7bed4901c15a2d0755c0ce513899363b27fab77 [file] [log] [blame]
/**
* 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);