diff --git a/docs/.gitignore b/docs/.gitignore index bdf3078bb36..d5cf1228397 100644 --- a/docs/.gitignore +++ b/docs/.gitignore @@ -37,10 +37,10 @@ developer_docs/api/ # Generated component documentation MDX files (regenerated at build time) # Source of truth is Storybook stories in superset-frontend/packages/superset-ui-core/src/components/ -developer_portal/components/ - -# Generated extension component documentation (regenerated at build time) -developer_portal/extensions/components/ +developer_docs/components/ # Note: src/data/databases.json is COMMITTED (not ignored) to preserve feature diagnostics # that require Flask context to generate. Update it locally with: npm run gen-db-docs + +# Generated component metadata JSON (regenerated by generate-superset-components.mjs) +static/data/components.json diff --git a/docs/developer_docs/api.mdx b/docs/developer_docs/api.mdx index ff563805a8e..09f3843087f 100644 --- a/docs/developer_docs/api.mdx +++ b/docs/developer_docs/api.mdx @@ -68,14 +68,14 @@ curl -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \ | `POST` | [Create a new dashboard](./api/create-a-new-dashboard) | `/api/v1/dashboard/` | | `GET` | [Get metadata information about this API resource (dashboard--info)](./api/get-metadata-information-about-this-api-resource-dashboard-info) | `/api/v1/dashboard/_info` | | `GET` | [Get a dashboard detail information](./api/get-a-dashboard-detail-information) | `/api/v1/dashboard/{id_or_slug}` | -| `GET` | [Get a dashboard's chart definitions.](./api/get-a-dashboards-chart-definitions) | `/api/v1/dashboard/{id_or_slug}/charts` | +| `GET` | [Get a dashboard's chart definitions.](./api/get-a-dashboard-s-chart-definitions) | `/api/v1/dashboard/{id_or_slug}/charts` | | `POST` | [Create a copy of an existing dashboard](./api/create-a-copy-of-an-existing-dashboard) | `/api/v1/dashboard/{id_or_slug}/copy/` | -| `GET` | [Get dashboard's datasets](./api/get-dashboards-datasets) | `/api/v1/dashboard/{id_or_slug}/datasets` | -| `DELETE` | [Delete a dashboard's embedded configuration](./api/delete-a-dashboards-embedded-configuration) | `/api/v1/dashboard/{id_or_slug}/embedded` | -| `GET` | [Get the dashboard's embedded configuration](./api/get-the-dashboards-embedded-configuration) | `/api/v1/dashboard/{id_or_slug}/embedded` | -| `POST` | [Set a dashboard's embedded configuration](./api/set-a-dashboards-embedded-configuration) | `/api/v1/dashboard/{id_or_slug}/embedded` | +| `GET` | [Get dashboard's datasets](./api/get-dashboard-s-datasets) | `/api/v1/dashboard/{id_or_slug}/datasets` | +| `DELETE` | [Delete a dashboard's embedded configuration](./api/delete-a-dashboard-s-embedded-configuration) | `/api/v1/dashboard/{id_or_slug}/embedded` | +| `GET` | [Get the dashboard's embedded configuration](./api/get-the-dashboard-s-embedded-configuration) | `/api/v1/dashboard/{id_or_slug}/embedded` | +| `POST` | [Set a dashboard's embedded configuration](./api/set-a-dashboard-s-embedded-configuration) | `/api/v1/dashboard/{id_or_slug}/embedded` | | `PUT` | [Update dashboard by id_or_slug embedded](./api/update-dashboard-by-id-or-slug-embedded) | `/api/v1/dashboard/{id_or_slug}/embedded` | -| `GET` | [Get dashboard's tabs](./api/get-dashboards-tabs) | `/api/v1/dashboard/{id_or_slug}/tabs` | +| `GET` | [Get dashboard's tabs](./api/get-dashboard-s-tabs) | `/api/v1/dashboard/{id_or_slug}/tabs` | | `DELETE` | [Delete a dashboard](./api/delete-a-dashboard) | `/api/v1/dashboard/{pk}` | | `PUT` | [Update a dashboard](./api/update-a-dashboard) | `/api/v1/dashboard/{pk}` | | `POST` | [Compute and cache a screenshot (dashboard-pk-cache-dashboard-screenshot)](./api/compute-and-cache-a-screenshot-dashboard-pk-cache-dashboard-screenshot) | `/api/v1/dashboard/{pk}/cache_dashboard_screenshot/` | @@ -84,7 +84,7 @@ curl -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \ | `POST` | [Mark the dashboard as favorite for the current user](./api/mark-the-dashboard-as-favorite-for-the-current-user) | `/api/v1/dashboard/{pk}/favorites/` | | `PUT` | [Update native filters configuration for a dashboard.](./api/update-native-filters-configuration-for-a-dashboard) | `/api/v1/dashboard/{pk}/filters` | | `GET` | [Get a computed screenshot from cache (dashboard-pk-screenshot-digest)](./api/get-a-computed-screenshot-from-cache-dashboard-pk-screenshot-digest) | `/api/v1/dashboard/{pk}/screenshot/{digest}/` | -| `GET` | [Get dashboard's thumbnail](./api/get-dashboards-thumbnail) | `/api/v1/dashboard/{pk}/thumbnail/{digest}/` | +| `GET` | [Get dashboard's thumbnail](./api/get-dashboard-s-thumbnail) | `/api/v1/dashboard/{pk}/thumbnail/{digest}/` | | `GET` | [Download multiple dashboards as YAML files](./api/download-multiple-dashboards-as-yaml-files) | `/api/v1/dashboard/export/` | | `GET` | [Check favorited dashboards for current user](./api/check-favorited-dashboards-for-current-user) | `/api/v1/dashboard/favorite_status/` | | `POST` | [Import dashboard(s) with associated charts/datasets/databases](./api/import-dashboard-s-with-associated-charts-datasets-databases) | `/api/v1/dashboard/import/` | @@ -177,7 +177,7 @@ curl -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \ | `GET` | [Get names of databases currently available](./api/get-names-of-databases-currently-available) | `/api/v1/database/available/` | | `GET` | [Download database(s) and associated dataset(s) as a zip file](./api/download-database-s-and-associated-dataset-s-as-a-zip-file) | `/api/v1/database/export/` | | `POST` | [Import database(s) with associated datasets](./api/import-database-s-with-associated-datasets) | `/api/v1/database/import/` | -| `GET` | [Receive personal access tokens from OAuth2](./api/receive-personal-access-tokens-from-o-auth-2) | `/api/v1/database/oauth2/` | +| `GET` | [Receive personal access tokens from OAuth2](./api/receive-personal-access-tokens-from-oauth2) | `/api/v1/database/oauth2/` | | `GET` | [Get related fields data (database-related-column-name)](./api/get-related-fields-data-database-related-column-name) | `/api/v1/database/related/{column_name}` | | `POST` | [Test a database connection](./api/test-a-database-connection) | `/api/v1/database/test_connection/` | | `POST` | [Upload a file and returns file metadata](./api/upload-a-file-and-returns-file-metadata) | `/api/v1/database/upload_metadata/` | @@ -201,7 +201,7 @@ curl -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \ | Method | Endpoint | Description | |--------|----------|-------------| -| `GET` | [Get the bootstrap data for SqlLab page](./api/get-the-bootstrap-data-for-sql-lab-page) | `/api/v1/sqllab/` | +| `GET` | [Get the bootstrap data for SqlLab page](./api/get-the-bootstrap-data-for-sqllab-page) | `/api/v1/sqllab/` | | `POST` | [Estimate the SQL query execution cost](./api/estimate-the-sql-query-execution-cost) | `/api/v1/sqllab/estimate/` | | `POST` | [Execute a SQL query](./api/execute-a-sql-query) | `/api/v1/sqllab/execute/` | | `GET` | [Export the SQL query results to a CSV](./api/export-the-sql-query-results-to-a-csv) | `/api/v1/sqllab/export/{client_id}/` | @@ -249,7 +249,7 @@ curl -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \ | Method | Endpoint | Description | |--------|----------|-------------| -| `GET` | [Return an AdvancedDataTypeResponse](./api/return-an-advanced-data-type-response) | `/api/v1/advanced_data_type/convert` | +| `GET` | [Return an AdvancedDataTypeResponse](./api/return-an-advanceddatatyperesponse) | `/api/v1/advanced_data_type/convert` | | `GET` | [Return a list of available advanced data types](./api/return-a-list-of-available-advanced-data-types) | `/api/v1/advanced_data_type/types` | @@ -324,8 +324,8 @@ curl -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \ | Method | Endpoint | Description | |--------|----------|-------------| -| `POST` | [Create a new dashboard's permanent link](./api/create-a-new-dashboards-permanent-link) | `/api/v1/dashboard/{pk}/permalink` | -| `GET` | [Get dashboard's permanent link state](./api/get-dashboards-permanent-link-state) | `/api/v1/dashboard/permalink/{key}` | +| `POST` | [Create a new dashboard's permanent link](./api/create-a-new-dashboard-s-permanent-link) | `/api/v1/dashboard/{pk}/permalink` | +| `GET` | [Get dashboard's permanent link state](./api/get-dashboard-s-permanent-link-state) | `/api/v1/dashboard/permalink/{key}` | @@ -335,7 +335,7 @@ curl -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \ | Method | Endpoint | Description | |--------|----------|-------------| | `POST` | [Create a new permanent link (explore-permalink)](./api/create-a-new-permanent-link-explore-permalink) | `/api/v1/explore/permalink` | -| `GET` | [Get chart's permanent link state](./api/get-charts-permanent-link-state) | `/api/v1/explore/permalink/{key}` | +| `GET` | [Get chart's permanent link state](./api/get-chart-s-permanent-link-state) | `/api/v1/explore/permalink/{key}` | @@ -345,7 +345,7 @@ curl -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \ | Method | Endpoint | Description | |--------|----------|-------------| | `POST` | [Create a new permanent link (sqllab-permalink)](./api/create-a-new-permanent-link-sqllab-permalink) | `/api/v1/sqllab/permalink` | -| `GET` | [Get permanent link state for SQLLab editor.](./api/get-permanent-link-state-for-sql-lab-editor) | `/api/v1/sqllab/permalink/{key}` | +| `GET` | [Get permanent link state for SQLLab editor.](./api/get-permanent-link-state-for-sqllab-editor) | `/api/v1/sqllab/permalink/{key}` | @@ -363,10 +363,10 @@ curl -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \ | Method | Endpoint | Description | |--------|----------|-------------| -| `POST` | [Create a dashboard's filter state](./api/create-a-dashboards-filter-state) | `/api/v1/dashboard/{pk}/filter_state` | -| `DELETE` | [Delete a dashboard's filter state value](./api/delete-a-dashboards-filter-state-value) | `/api/v1/dashboard/{pk}/filter_state/{key}` | -| `GET` | [Get a dashboard's filter state value](./api/get-a-dashboards-filter-state-value) | `/api/v1/dashboard/{pk}/filter_state/{key}` | -| `PUT` | [Update a dashboard's filter state value](./api/update-a-dashboards-filter-state-value) | `/api/v1/dashboard/{pk}/filter_state/{key}` | +| `POST` | [Create a dashboard's filter state](./api/create-a-dashboard-s-filter-state) | `/api/v1/dashboard/{pk}/filter_state` | +| `DELETE` | [Delete a dashboard's filter state value](./api/delete-a-dashboard-s-filter-state-value) | `/api/v1/dashboard/{pk}/filter_state/{key}` | +| `GET` | [Get a dashboard's filter state value](./api/get-a-dashboard-s-filter-state-value) | `/api/v1/dashboard/{pk}/filter_state/{key}` | +| `PUT` | [Update a dashboard's filter state value](./api/update-a-dashboard-s-filter-state-value) | `/api/v1/dashboard/{pk}/filter_state/{key}` | diff --git a/docs/developer_docs/extensions/development.md b/docs/developer_docs/extensions/development.md index c1f7a51be5e..41b112abb89 100644 --- a/docs/developer_docs/extensions/development.md +++ b/docs/developer_docs/extensions/development.md @@ -309,7 +309,7 @@ InteractiveMyComponent.argTypes = { When the docs site is built (`yarn start` or `yarn build` in the `docs/` directory): -1. The `generate-extension-components` script scans all stories in `superset-core` +1. The `generate-superset-components` script scans all stories (including `superset-core`) 2. For each story, it generates an MDX page with: - Component description - **Live interactive example** with controls extracted from `argTypes` diff --git a/docs/developer_docs/extensions/overview.md b/docs/developer_docs/extensions/overview.md index 175fa9701ca..be8628836d8 100644 --- a/docs/developer_docs/extensions/overview.md +++ b/docs/developer_docs/extensions/overview.md @@ -41,6 +41,10 @@ Extensions can provide: - **REST API Endpoints**: Backend services under the `/api/v1/extensions/` namespace - **MCP Tools and Prompts**: AI agent capabilities for enhanced user assistance +## UI Components for Extensions + +Extension developers have access to pre-built UI components via `@apache-superset/core/ui`. Browse all available components on the [UI Components](/docs/components/) page and filter by **Extension Compatible** to see components available to extensions. + ## Next Steps - **[Quick Start](./quick-start)** - Build your first extension with a complete walkthrough diff --git a/docs/docusaurus.config.ts b/docs/docusaurus.config.ts index 289ae9ed0e5..898bf3ff1c8 100644 --- a/docs/docusaurus.config.ts +++ b/docs/docusaurus.config.ts @@ -227,7 +227,35 @@ if (!versionsConfig.developer_docs.disabled && !versionsConfig.developer_docs.hi }); } +// Docusaurus Faster: Rspack bundler, SWC transpilation, and other build +// optimizations. Only enabled for local development — CI runners (GitHub +// Actions, Netlify) have ~8GB RAM and these features push memory usage over +// the limit. See https://docusaurus.io/blog/releases/3.6#docusaurus-faster +const isCI = process.env.CI === 'true'; + const config: Config = { + ...(!isCI && { + future: { + v4: { + removeLegacyPostBuildHeadAttribute: true, + // Disabled: CSS cascade layers change specificity and cause antd + // styles (from Storybook component pages) to override theme styles + useCssCascadeLayers: false, + }, + experimental_faster: { + swcJsLoader: true, + swcJsMinimizer: true, + swcHtmlMinimizer: true, + lightningCssMinimizer: true, + rspackBundler: true, + mdxCrossCompilerCache: true, + rspackPersistentCache: true, + // SSG worker threads spawn parallel Node processes, each consuming + // significant memory. Disabled to keep total usage reasonable. + ssgWorkerThreads: false, + }, + }, + }), title: 'Superset', tagline: 'Apache Superset is a modern data exploration and visualization platform', diff --git a/docs/package.json b/docs/package.json index b41324c9f3f..0d11ac24179 100644 --- a/docs/package.json +++ b/docs/package.json @@ -6,10 +6,12 @@ "scripts": { "docusaurus": "docusaurus", "_init": "cat src/intro_header.txt ../README.md > docs/intro.md", - "start": "yarn run _init && yarn run generate:all && NODE_OPTIONS='--max-old-space-size=8192' NODE_ENV=development docusaurus start", + "start": "yarn run _init && yarn run generate:smart && NODE_OPTIONS='--max-old-space-size=8192' NODE_ENV=development docusaurus start", "start:quick": "yarn run _init && NODE_OPTIONS='--max-old-space-size=8192' NODE_ENV=development docusaurus start", + "start:full": "yarn run _init && yarn run generate:all && NODE_OPTIONS='--max-old-space-size=8192' NODE_ENV=development docusaurus start", "stop": "pkill -9 -f 'docusaurus start' || pkill -9 -f 'docusaurus serve' || echo 'No docusaurus server running'", - "build": "yarn run _init && yarn run generate:all && NODE_OPTIONS='--max-old-space-size=8192' DEBUG=docusaurus:* docusaurus build", + "build": "yarn run _init && yarn run generate:smart && NODE_OPTIONS='--max-old-space-size=8192' docusaurus build", + "build:full": "yarn run _init && yarn run generate:all && NODE_OPTIONS='--max-old-space-size=8192' docusaurus build", "generate:api-docs": "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", "clean:api-docs": "docusaurus clean-api-docs superset", "swizzle": "docusaurus swizzle", @@ -19,11 +21,11 @@ "write-translations": "docusaurus write-translations", "write-heading-ids": "docusaurus write-heading-ids", "typecheck": "yarn run generate:all && tsc", - "generate:extension-components": "node scripts/generate-extension-components.mjs", "generate:superset-components": "node scripts/generate-superset-components.mjs", "generate:database-docs": "node scripts/generate-database-docs.mjs", "gen-db-docs": "node scripts/generate-database-docs.mjs", - "generate:all": "yarn run generate:extension-components & yarn run generate:superset-components & yarn run generate:database-docs & wait && yarn run generate:api-docs", + "generate:smart": "node scripts/generate-if-changed.mjs", + "generate:all": "node scripts/generate-if-changed.mjs --force", "lint:db-metadata": "python3 ../superset/db_engine_specs/lint_metadata.py", "lint:db-metadata:report": "python3 ../superset/db_engine_specs/lint_metadata.py --markdown -o ../superset/db_engine_specs/METADATA_STATUS.md", "update:readme-db-logos": "node scripts/generate-database-docs.mjs --update-readme", @@ -40,6 +42,7 @@ "dependencies": { "@ant-design/icons": "^6.1.0", "@docusaurus/core": "3.9.2", + "@docusaurus/faster": "^3.9.2", "@docusaurus/plugin-client-redirects": "3.9.2", "@docusaurus/preset-classic": "3.9.2", "@docusaurus/theme-live-codeblock": "^3.9.2", diff --git a/docs/scripts/generate-database-docs.mjs b/docs/scripts/generate-database-docs.mjs index 85e5b18ff0c..0491683b951 100644 --- a/docs/scripts/generate-database-docs.mjs +++ b/docs/scripts/generate-database-docs.mjs @@ -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'; - +export const databaseInfo = ${inlineData}; + + `; } diff --git a/docs/scripts/generate-extension-components.mjs b/docs/scripts/generate-extension-components.mjs deleted file mode 100644 index c7bed4901c1..00000000000 --- a/docs/scripts/generate-extension-components.mjs +++ /dev/null @@ -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} ---- - - - -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 - - - -## 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 ---- - - - -# 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 ( - - Welcome to my extension! - - ); -} -\`\`\` - -## 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) => ; - -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>;`); - } - } - } - - // 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); diff --git a/docs/scripts/generate-if-changed.mjs b/docs/scripts/generate-if-changed.mjs new file mode 100644 index 00000000000..b44d5e77ff5 --- /dev/null +++ b/docs/scripts/generate-if-changed.mjs @@ -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, '<<>>') + .replace(/\*/g, '[^/]*') + .replace(/<<>>/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); +}); diff --git a/docs/scripts/generate-superset-components.mjs b/docs/scripts/generate-superset-components.mjs index a85a49bef0a..55ee639293f 100644 --- a/docs/scripts/generate-superset-components.mjs +++ b/docs/scripts/generate-superset-components.mjs @@ -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 + + + +--- + +## 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/ Atoms = Foundations, Molecules = Components, Organisms = Patterns, Templates = Templates, Pages / Screens = Features ---- - -## Component Library - -Interactive documentation for Superset's UI component library. **${totalComponents} components** documented across ${Object.keys(categories).filter(k => categories[k].length > 0).length} categories. - -${categoryList} - ## Usage All components are exported from \`@superset-ui/core/components\`: @@ -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 = ;" 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>;` + ); + } + } + + // 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)`); diff --git a/docs/sidebarTutorials.js b/docs/sidebarTutorials.js index 77839807ade..807b1976091 100644 --- a/docs/sidebarTutorials.js +++ b/docs/sidebarTutorials.js @@ -75,17 +75,6 @@ const sidebars = { 'extensions/architecture', 'extensions/dependencies', 'extensions/contribution-types', - { - type: 'category', - label: 'Components', - collapsed: true, - items: [ - { - type: 'autogenerated', - dirName: 'extensions/components', - }, - ], - }, { type: 'category', label: 'Extension Points', diff --git a/docs/src/components/databases/DatabaseIndex.tsx b/docs/src/components/databases/DatabaseIndex.tsx index 26f71522dfc..91c58964a31 100644 --- a/docs/src/components/databases/DatabaseIndex.tsx +++ b/docs/src/components/databases/DatabaseIndex.tsx @@ -496,6 +496,7 @@ const DatabaseIndex: React.FC = ({ data }) => { return (
+ {/* Statistics Cards */} {/* Statistics Cards */} diff --git a/docs/src/components/ui-components/ComponentIndex.tsx b/docs/src/components/ui-components/ComponentIndex.tsx new file mode 100644 index 00000000000..180a67ea2db --- /dev/null +++ b/docs/src/components/ui-components/ComponentIndex.tsx @@ -0,0 +1,262 @@ +/** + * 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 React, { useState, useMemo } from 'react'; +import { Card, Row, Col, Statistic, Table, Tag, Input, Select } from 'antd'; +import { + AppstoreOutlined, + ApiOutlined, + PictureOutlined, + PlayCircleOutlined, + SearchOutlined, +} from '@ant-design/icons'; +import type { ComponentData, ComponentEntry } from './types'; + +interface ComponentIndexProps { + data: ComponentData; +} + +const CATEGORY_COLORS: Record = { + ui: 'blue', + 'design-system': 'green', + extension: 'purple', +}; + +const CATEGORY_LABELS: Record = { + ui: 'Core', + 'design-system': 'Layout', + extension: 'Extension', +}; + +const ComponentIndex: React.FC = ({ data }) => { + const [searchText, setSearchText] = useState(''); + const [categoryFilter, setCategoryFilter] = useState(null); + + const { statistics, components } = data; + + const filteredComponents = useMemo(() => { + return components + .filter((comp) => { + const matchesSearch = + !searchText || + comp.name.toLowerCase().includes(searchText.toLowerCase()) || + comp.description.toLowerCase().includes(searchText.toLowerCase()) || + comp.package.toLowerCase().includes(searchText.toLowerCase()); + const matchesCategory = + !categoryFilter || comp.category === categoryFilter; + return matchesSearch && matchesCategory; + }) + .sort((a, b) => a.name.localeCompare(b.name)); + }, [components, searchText, categoryFilter]); + + const { categories, categoryCounts } = useMemo(() => { + const counts: Record = {}; + components.forEach((comp) => { + counts[comp.category] = (counts[comp.category] || 0) + 1; + }); + return { + categories: Object.keys(counts).sort(), + categoryCounts: counts, + }; + }, [components]); + + const columns = [ + { + title: 'Component', + dataIndex: 'name', + key: 'name', + sorter: (a: ComponentEntry, b: ComponentEntry) => + a.name.localeCompare(b.name), + defaultSortOrder: 'ascend' as const, + render: (name: string, record: ComponentEntry) => ( +
+ + {name} + + {record.description && ( +
+ {record.description.slice(0, 100)} + {record.description.length > 100 ? '...' : ''} +
+ )} +
+ ), + }, + { + title: 'Category', + dataIndex: 'category', + key: 'category', + width: 120, + filters: categories.map((cat) => ({ + text: CATEGORY_LABELS[cat] || cat, + value: cat, + })), + onFilter: (value: React.Key | boolean, record: ComponentEntry) => + record.category === value, + render: (cat: string) => ( + + {CATEGORY_LABELS[cat] || cat} + + ), + }, + { + title: 'Package', + dataIndex: 'package', + key: 'package', + width: 220, + render: (pkg: string) => ( + {pkg} + ), + }, + { + title: 'Tags', + key: 'tags', + width: 280, + filters: [ + { text: 'Extension Compatible', value: 'extension' }, + { text: 'Gallery', value: 'gallery' }, + { text: 'Live Demo', value: 'demo' }, + ], + onFilter: (value: React.Key | boolean, record: ComponentEntry) => { + switch (value) { + case 'extension': + return record.extensionCompatible; + case 'gallery': + return record.hasGallery; + case 'demo': + return record.hasLiveExample; + default: + return true; + } + }, + render: (_: unknown, record: ComponentEntry) => ( +
+ {record.extensionCompatible && ( + Extension Compatible + )} + {record.hasGallery && Gallery} + {record.hasLiveExample && Demo} +
+ ), + }, + { + title: 'Props', + dataIndex: 'propsCount', + key: 'propsCount', + width: 80, + sorter: (a: ComponentEntry, b: ComponentEntry) => + a.propsCount - b.propsCount, + render: (count: number) => ( + 0 ? '#1890ff' : '#999' }}>{count} + ), + }, + ]; + + return ( +
+ + + + } + /> + + + + + } + /> + + + + + } + /> + + + + + } + /> + + + + + + + } + value={searchText} + onChange={(e) => setSearchText(e.target.value)} + allowClear + /> + + +