mirror of
https://github.com/apache/superset.git
synced 2026-04-07 18:35:15 +00:00
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:
@@ -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} />
|
||||
`;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
307
docs/scripts/generate-if-changed.mjs
Normal file
307
docs/scripts/generate-if-changed.mjs
Normal 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);
|
||||
});
|
||||
@@ -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)`);
|
||||
|
||||
Reference in New Issue
Block a user