| #!/usr/bin/env node |
| /** |
| * 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. |
| */ |
| |
| /** |
| * Custom rule checker for Superset-specific linting patterns |
| * Runs as a separate check without needing custom binaries |
| */ |
| |
| const fs = require('fs'); |
| const path = require('path'); |
| const glob = require('glob'); |
| const parser = require('@babel/parser'); |
| const traverse = require('@babel/traverse').default; |
| |
| // ANSI color codes |
| const RED = '\x1B[31m'; |
| const YELLOW = '\x1B[33m'; |
| const RESET = '\x1B[0m'; |
| |
| let errorCount = 0; |
| let warningCount = 0; |
| |
| /** |
| * Check if a node has an eslint-disable comment |
| */ |
| function hasEslintDisable(path, ruleName = 'theme-colors/no-literal-colors') { |
| const { node, parent } = path; |
| |
| // Check leadingComments on the node itself |
| if (node.leadingComments) { |
| const hasDisable = node.leadingComments.some( |
| comment => |
| (comment.value.includes('eslint-disable-next-line') || |
| comment.value.includes('eslint-disable')) && |
| comment.value.includes(ruleName), |
| ); |
| if (hasDisable) return true; |
| } |
| |
| // Check leadingComments on parent nodes (for expressions in assignments, etc.) |
| if (parent && parent.leadingComments) { |
| const hasDisable = parent.leadingComments.some( |
| comment => |
| (comment.value.includes('eslint-disable-next-line') || |
| comment.value.includes('eslint-disable')) && |
| comment.value.includes(ruleName), |
| ); |
| if (hasDisable) return true; |
| } |
| |
| // Check if parent is a statement with leading comments |
| let current = path; |
| while (current.parent) { |
| current = current.parent; |
| if (current.node && current.node.leadingComments) { |
| const hasDisable = current.node.leadingComments.some( |
| comment => |
| (comment.value.includes('eslint-disable-next-line') || |
| comment.value.includes('eslint-disable')) && |
| comment.value.includes(ruleName), |
| ); |
| if (hasDisable) return true; |
| } |
| } |
| |
| return false; |
| } |
| |
| /** |
| * Check for literal color values (hex, rgb, rgba) |
| */ |
| function checkNoLiteralColors(ast, filepath) { |
| const colorPatterns = [ |
| /^#[0-9A-Fa-f]{3,6}$/, // Hex colors |
| /^rgb\(/, // RGB colors |
| /^rgba\(/, // RGBA colors |
| ]; |
| |
| traverse(ast, { |
| StringLiteral(path) { |
| const { value } = path.node; |
| if (colorPatterns.some(pattern => pattern.test(value))) { |
| // Check if this line has an eslint-disable comment |
| if (hasEslintDisable(path)) { |
| return; // Skip this violation |
| } |
| |
| // eslint-disable-next-line no-console |
| console.error( |
| `${RED}✖${RESET} ${filepath}: Literal color "${value}" found. Use theme colors instead.`, |
| ); |
| errorCount += 1; |
| } |
| }, |
| // Check styled-components template literals |
| TemplateLiteral(path) { |
| path.node.quasis.forEach(quasi => { |
| const value = quasi.value.raw; |
| // Look for CSS color properties |
| if ( |
| value.match( |
| /(?:color|background|border-color|outline-color):\s*(#[0-9A-Fa-f]{3,6}|rgb|rgba)/, |
| ) |
| ) { |
| // Check if this line has an eslint-disable comment |
| if (hasEslintDisable(path)) { |
| return; // Skip this violation |
| } |
| |
| // eslint-disable-next-line no-console |
| console.error( |
| `${RED}✖${RESET} ${filepath}: Literal color in styled component. Use theme colors instead.`, |
| ); |
| errorCount += 1; |
| } |
| }); |
| }, |
| }); |
| } |
| |
| /** |
| * Check for FontAwesome icon usage |
| */ |
| function checkNoFaIcons(ast, filepath) { |
| traverse(ast, { |
| ImportDeclaration(path) { |
| const source = path.node.source.value; |
| if (source.includes('@fortawesome') || source.includes('font-awesome')) { |
| // eslint-disable-next-line no-console |
| console.error( |
| `${RED}✖${RESET} ${filepath}: FontAwesome import detected. Use @superset-ui/core/components/Icons instead.`, |
| ); |
| errorCount += 1; |
| } |
| }, |
| JSXAttribute(path) { |
| if (path.node.name.name === 'className') { |
| const { value } = path.node; |
| if ( |
| value && |
| value.type === 'StringLiteral' && |
| value.value.includes('fa-') |
| ) { |
| // eslint-disable-next-line no-console |
| console.error( |
| `${RED}✖${RESET} ${filepath}: FontAwesome class detected. Use Icons component instead.`, |
| ); |
| errorCount += 1; |
| } |
| } |
| }, |
| }); |
| } |
| |
| /** |
| * Check for improper i18n template usage |
| */ |
| function checkI18nTemplates(ast, filepath) { |
| traverse(ast, { |
| CallExpression(path) { |
| const { callee } = path.node; |
| // Check for t() or tn() functions |
| if ( |
| callee.type === 'Identifier' && |
| (callee.name === 't' || callee.name === 'tn') |
| ) { |
| const args = path.node.arguments; |
| if (args.length > 0 && args[0].type === 'TemplateLiteral') { |
| const templateLiteral = args[0]; |
| if (templateLiteral.expressions.length > 0) { |
| // eslint-disable-next-line no-console |
| console.error( |
| `${RED}✖${RESET} ${filepath}: Template variables in t() function. Use parameterized messages instead.`, |
| ); |
| errorCount += 1; |
| } |
| } |
| } |
| }, |
| }); |
| } |
| |
| /** |
| * Check for eager t()/tn() calls in `label` / `description` properties of |
| * config objects evaluated at module load (e.g., controlPanel files). The |
| * translation is captured at module-evaluation time, before i18n has loaded, |
| * and never updates when the user switches language. The fix is to wrap the |
| * call in an arrow function: `label: () => t('Foo')`. |
| * |
| * Limited to controlPanel files because that's where this pattern is |
| * problematic at scale; t() inside JSX or component bodies is evaluated at |
| * render time and works fine. |
| */ |
| const EAGER_T_WATCHED_PROPS = new Set(['label', 'description']); |
| |
| function checkEagerTranslationsInConfig(ast, filepath) { |
| if (!/controlPanel\.(ts|tsx|js|jsx)$/.test(filepath)) return; |
| |
| traverse(ast, { |
| ObjectProperty(path) { |
| const { node } = path; |
| if (node.computed || node.shorthand) return; |
| |
| const keyName = |
| node.key.type === 'Identifier' |
| ? node.key.name |
| : node.key.type === 'StringLiteral' |
| ? node.key.value |
| : null; |
| if (!keyName || !EAGER_T_WATCHED_PROPS.has(keyName)) return; |
| |
| const { value } = node; |
| if ( |
| value.type !== 'CallExpression' || |
| value.callee.type !== 'Identifier' || |
| (value.callee.name !== 't' && value.callee.name !== 'tn') |
| ) { |
| return; |
| } |
| |
| if (hasEslintDisable(path, 'i18n-strings/no-eager-t-in-config')) return; |
| |
| // Warn (not error) because there are many pre-existing violations. |
| // The ESLint plugin provides an autofix so authors can sweep files |
| // as they touch them. Promote to error once the codebase is clean. |
| // eslint-disable-next-line no-console |
| console.warn( |
| `${YELLOW}⚠${RESET} ${filepath}:${node.loc?.start.line ?? '?'}: ` + |
| `Eager \`${keyName}: ${value.callee.name}(...)\` is evaluated at ` + |
| `module load, before i18n is initialized. Wrap in an arrow ` + |
| `function: \`${keyName}: () => ${value.callee.name}(...)\`. ` + |
| `Run \`eslint --fix\` to autofix.`, |
| ); |
| warningCount += 1; |
| }, |
| }); |
| } |
| |
| /** |
| * Props that should contain translated strings |
| */ |
| const TRANSLATABLE_PROPS = new Set([ |
| 'title', |
| 'placeholder', |
| 'label', |
| 'alt', |
| 'aria-label', |
| 'aria-placeholder', |
| 'aria-roledescription', |
| 'aria-valuetext', |
| ]); |
| |
| /** |
| * Props that should NOT be checked for translation |
| */ |
| const IGNORED_PROPS = new Set([ |
| 'className', |
| 'id', |
| 'name', |
| 'type', |
| 'role', |
| 'href', |
| 'src', |
| 'key', |
| 'data-test', |
| 'data-testid', |
| 'htmlFor', |
| 'target', |
| 'rel', |
| 'method', |
| 'action', |
| 'pattern', |
| 'accept', |
| 'autoComplete', |
| 'inputMode', |
| 'lang', |
| 'dir', |
| 'xmlns', |
| 'viewBox', |
| 'd', |
| 'fill', |
| 'stroke', |
| 'transform', |
| 'style', |
| 'dangerouslySetInnerHTML', |
| ]); |
| |
| /** |
| * SQL keywords and technical terms that should not be translated |
| */ |
| const TECHNICAL_TERMS = new Set([ |
| // SQL keywords |
| 'SELECT', |
| 'FROM', |
| 'WHERE', |
| 'HAVING', |
| 'GROUP', |
| 'ORDER', |
| 'BY', |
| 'JOIN', |
| 'LEFT', |
| 'RIGHT', |
| 'INNER', |
| 'OUTER', |
| 'FULL', |
| 'CROSS', |
| 'ON', |
| 'AND', |
| 'OR', |
| 'NOT', |
| 'IN', |
| 'EXISTS', |
| 'BETWEEN', |
| 'LIKE', |
| 'IS', |
| 'NULL', |
| 'TRUE', |
| 'FALSE', |
| 'ASC', |
| 'DESC', |
| 'LIMIT', |
| 'OFFSET', |
| 'UNION', |
| 'ALL', |
| 'DISTINCT', |
| 'AS', |
| 'CASE', |
| 'WHEN', |
| 'THEN', |
| 'ELSE', |
| 'END', |
| 'CAST', |
| 'CONVERT', |
| // SQL date functions (common in Superset) |
| 'DATETIME', |
| 'DATEADD', |
| 'DATETRUNC', |
| 'LASTDAY', |
| 'HOLIDAY', |
| 'DATE', |
| 'TIME', |
| 'TIMESTAMP', |
| 'YEAR', |
| 'MONTH', |
| 'DAY', |
| 'HOUR', |
| 'MINUTE', |
| 'SECOND', |
| 'WEEK', |
| // Data types |
| 'JSON', |
| 'XML', |
| 'CSV', |
| 'INT', |
| 'INTEGER', |
| 'FLOAT', |
| 'DOUBLE', |
| 'VARCHAR', |
| 'CHAR', |
| 'TEXT', |
| 'BOOLEAN', |
| 'BOOL', |
| 'BIGINT', |
| 'SMALLINT', |
| 'DECIMAL', |
| // Technical abbreviations |
| 'SQL', |
| 'API', |
| 'URL', |
| 'URI', |
| 'HTML', |
| 'CSS', |
| 'JS', |
| 'TS', |
| 'ID', |
| 'UUID', |
| 'HTTP', |
| 'HTTPS', |
| 'GET', |
| 'POST', |
| 'PUT', |
| 'DELETE', |
| 'PATCH', |
| // Error/status indicators that are typically not translated |
| 'OK', |
| 'ERROR', |
| 'WARNING', |
| 'INFO', |
| 'DEBUG', |
| 'N/A', |
| 'TBD', |
| ]); |
| |
| /** |
| * Check if a string looks like it needs translation |
| * Returns false for technical strings, identifiers, etc. |
| */ |
| function needsTranslation(value) { |
| if (typeof value !== 'string') return false; |
| |
| const trimmed = value.trim(); |
| |
| // Empty or whitespace-only strings don't need translation |
| if (!trimmed) return false; |
| |
| // Single characters don't need translation |
| if (trimmed.length === 1) return false; |
| |
| // Pure numbers don't need translation |
| if (/^-?\d+\.?\d*$/.test(trimmed)) return false; |
| |
| // Punctuation-only strings don't need translation |
| if (/^[^\w\s]+$/.test(trimmed)) return false; |
| |
| // URLs and paths don't need translation |
| if (/^(https?:\/\/|\/|\.\/|\.\.\/)/.test(trimmed)) return false; |
| |
| // File extensions don't need translation |
| if (/^\.\w+$/.test(trimmed)) return false; |
| |
| // CSS-like values (colors, sizes, etc.) |
| if ( |
| /^(#[0-9a-f]+|\d+(%|px|em|rem|vh|vw|pt|cm|mm|in)|none|auto|inherit|initial|unset)$/i.test( |
| trimmed, |
| ) |
| ) |
| return false; |
| |
| // camelCase or PascalCase identifiers (likely code, not user text) |
| if (/^[a-z][a-zA-Z0-9]*$/.test(trimmed) && /[A-Z]/.test(trimmed)) |
| return false; |
| |
| // snake_case or SCREAMING_SNAKE_CASE identifiers |
| if (/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(trimmed) && trimmed.includes('_')) |
| return false; |
| |
| // kebab-case identifiers (CSS classes, data attributes) |
| if (/^[a-z][a-z0-9-]*$/.test(trimmed) && trimmed.includes('-')) return false; |
| |
| // Looks like a variable or placeholder pattern |
| if (/^[%$]\w+$/.test(trimmed) || /^\{\{?\w+\}?\}$/.test(trimmed)) |
| return false; |
| |
| // Very short strings (2-3 chars) that are all lowercase are likely codes |
| if (trimmed.length <= 3 && /^[a-z]+$/.test(trimmed)) return false; |
| |
| // Single lowercase words up to 10 chars are often icon names or technical terms |
| // (e.g., "check", "stop", "down", "empty", "starred") |
| if (/^[a-z]+$/.test(trimmed) && trimmed.length <= 10) return false; |
| |
| // Code fragments (contains = or other code syntax) |
| if (/[=<>{}[\]]/.test(trimmed)) return false; |
| |
| // ALL_CAPS words are usually technical terms (SQL keywords, constants) |
| if (/^[A-Z][A-Z0-9]*$/.test(trimmed)) return false; |
| |
| // Known technical terms |
| if (TECHNICAL_TERMS.has(trimmed.toUpperCase())) return false; |
| |
| // Code-like patterns: function calls, SQL syntax examples |
| if (/^[a-zA-Z]+\s*\([^)]*\)/.test(trimmed)) return false; |
| |
| // SQL-like syntax: "SELECT * FROM", "GROUP BY", etc. |
| if (/^(SELECT|FROM|WHERE|GROUP|ORDER|HAVING)\s/i.test(trimmed)) return false; |
| |
| // Date format patterns (strftime, moment.js, etc.) |
| if (/^%[YymdHMSjWwUzZ%-]+$/.test(trimmed)) return false; |
| if (/^%[a-zA-Z][/%\-a-zA-Z]*$/.test(trimmed)) return false; |
| |
| // Format patterns with slashes/dashes (e.g., YYYY-MM-DD, mm/dd/yyyy) |
| if (/^[YMDHhms/\-:. ]+$/i.test(trimmed) && /[YMDHhms]{2,}/i.test(trimmed)) |
| return false; |
| |
| // Strings ending with colon followed by technical content are often labels |
| // But if it's just "Label:" with a space-containing label, it might need translation |
| |
| // Strings that are likely user-visible text (contains spaces or is a readable word) |
| return ( |
| /\s/.test(trimmed) || /^[A-Z][a-z]+/.test(trimmed) || trimmed.length > 3 |
| ); |
| } |
| |
| /** |
| * Check if a JSX expression is wrapped in t() or tn() |
| */ |
| function isWrappedInTranslation(node) { |
| if (!node) return false; |
| |
| // Direct t() or tn() call |
| if (node.type === 'CallExpression') { |
| const { callee } = node; |
| if ( |
| callee.type === 'Identifier' && |
| (callee.name === 't' || callee.name === 'tn') |
| ) { |
| return true; |
| } |
| } |
| |
| // JSX expression container with t() call |
| if (node.type === 'JSXExpressionContainer') { |
| return isWrappedInTranslation(node.expression); |
| } |
| |
| return false; |
| } |
| |
| /** |
| * Check for untranslated user-facing strings |
| */ |
| function checkUntranslatedStrings(ast, filepath) { |
| traverse(ast, { |
| // Check JSX attributes for untranslated strings |
| JSXAttribute(path) { |
| const attrName = |
| path.node.name.name || |
| (path.node.name.namespace |
| ? `${path.node.name.namespace.name}:${path.node.name.name.name}` |
| : null); |
| |
| if (!attrName) return; |
| |
| // Skip ignored props |
| if (IGNORED_PROPS.has(attrName)) return; |
| |
| // Skip data-* and aria-* props that aren't in our translatable list |
| if (attrName.startsWith('data-') && !TRANSLATABLE_PROPS.has(attrName)) |
| return; |
| if (attrName.startsWith('aria-') && !TRANSLATABLE_PROPS.has(attrName)) |
| return; |
| |
| // Only check props that should be translated |
| if (!TRANSLATABLE_PROPS.has(attrName)) return; |
| |
| const { value } = path.node; |
| |
| // String literal value |
| if (value && value.type === 'StringLiteral') { |
| if (needsTranslation(value.value)) { |
| if (hasEslintDisable(path, 'i18n/no-untranslated-string')) { |
| return; |
| } |
| // eslint-disable-next-line no-console |
| console.error( |
| `${RED}✖${RESET} ${filepath}: Untranslated string in "${attrName}" prop: "${value.value}". Wrap with t().`, |
| ); |
| errorCount += 1; |
| } |
| } |
| |
| // JSX expression that's not a t() call |
| if (value && value.type === 'JSXExpressionContainer') { |
| const { expression } = value; |
| if ( |
| expression.type === 'StringLiteral' && |
| needsTranslation(expression.value) |
| ) { |
| if (!isWrappedInTranslation(value)) { |
| if (hasEslintDisable(path, 'i18n/no-untranslated-string')) { |
| return; |
| } |
| // eslint-disable-next-line no-console |
| console.error( |
| `${RED}✖${RESET} ${filepath}: Untranslated string in "${attrName}" prop: "${expression.value}". Wrap with t().`, |
| ); |
| errorCount += 1; |
| } |
| } |
| } |
| }, |
| |
| // Check JSX text content |
| JSXText(path) { |
| const text = path.node.value.trim(); |
| |
| if (needsTranslation(text)) { |
| if (hasEslintDisable(path, 'i18n/no-untranslated-string')) { |
| return; |
| } |
| // eslint-disable-next-line no-console |
| console.error( |
| `${RED}✖${RESET} ${filepath}: Untranslated JSX text: "${text.substring(0, 50)}${text.length > 50 ? '...' : ''}". Wrap with {t('...')}.`, |
| ); |
| errorCount += 1; |
| } |
| }, |
| }); |
| } |
| |
| /** |
| * Process a single file |
| */ |
| function processFile(filepath) { |
| const code = fs.readFileSync(filepath, 'utf8'); |
| |
| try { |
| const ast = parser.parse(code, { |
| sourceType: 'module', |
| plugins: ['jsx', 'typescript', 'decorators-legacy'], |
| attachComments: true, |
| }); |
| |
| // Run all checks |
| checkNoLiteralColors(ast, filepath); |
| checkNoFaIcons(ast, filepath); |
| checkI18nTemplates(ast, filepath); |
| checkEagerTranslationsInConfig(ast, filepath); |
| checkUntranslatedStrings(ast, filepath); |
| } catch (error) { |
| // eslint-disable-next-line no-console |
| console.warn( |
| `${YELLOW}⚠${RESET} Could not parse ${filepath}: ${error.message}`, |
| ); |
| warningCount += 1; |
| } |
| } |
| |
| /** |
| * Main function |
| */ |
| function main() { |
| const args = process.argv.slice(2); |
| let files = args; |
| |
| // Define ignore patterns once |
| const ignorePatterns = [ |
| /\.test\./, |
| /\.spec\./, |
| /\/test\//, |
| /\/tests\//, |
| /\/storybook\//, |
| /^\.storybook\//, // .storybook directory at root |
| /\.stories\./, |
| /\/demo\//, |
| /\/examples\//, |
| /\/color\/colorSchemes\//, |
| /\/cypress\//, |
| /\/cypress-base\//, |
| /\/esm\//, |
| /\/lib\//, |
| /\/dist\//, |
| /plugins\/legacy-/, // Legacy plugins can have old color patterns |
| /plugin-chart-point-cluster-map\/src\/controlPanel/, // Data visualization color choices |
| /\/vendor\//, // Third-party vendor code |
| /spec\/fixtures\//, // Test fixtures |
| /theme\/exampleThemes/, // Theme examples legitimately have colors |
| /\/color\/utils/, // Color utility functions legitimately work with colors |
| /\/theme\/utils/, // Theme utility functions legitimately work with colors |
| /packages\/superset-ui-core\/src\/color\/index\.ts/, // Core brand color constants |
| ]; |
| |
| // If no files specified, check all |
| if (files.length === 0) { |
| files = glob.sync('src/**/*.{ts,tsx,js,jsx}', { |
| ignore: [ |
| '**/*.test.*', |
| '**/*.spec.*', |
| '**/test/**', |
| '**/tests/**', |
| '**/node_modules/**', |
| '**/storybook/**', |
| '**/*.stories.*', |
| '**/demo/**', |
| '**/examples/**', |
| '**/color/colorSchemes/**', // Color scheme definitions legitimately contain colors |
| '**/cypress/**', |
| '**/cypress-base/**', |
| '**/esm/**', // Build artifacts |
| '**/lib/**', // Build artifacts |
| '**/dist/**', // Build artifacts |
| 'plugins/legacy-*/**', // Legacy plugins |
| 'plugins/plugin-chart-point-cluster-map/src/controlPanel.*', // Data visualization color choices |
| '**/vendor/**', |
| 'spec/fixtures/**', |
| '**/theme/exampleThemes/**', |
| '**/color/utils/**', |
| '**/theme/utils/**', |
| 'packages/superset-ui-core/src/color/index.ts', // Core brand color constants |
| ], |
| }); |
| } else { |
| // Filter to only JS/TS files and remove superset-frontend prefix |
| files = files |
| .filter(f => /\.(ts|tsx|js|jsx)$/.test(f)) |
| .map(f => f.replace(/^superset-frontend\//, '')) |
| .filter(f => !ignorePatterns.some(pattern => pattern.test(f))); |
| } |
| |
| if (files.length === 0) { |
| // eslint-disable-next-line no-console |
| console.log('No files to check.'); |
| return; |
| } |
| |
| // eslint-disable-next-line no-console |
| console.log(`Checking ${files.length} files for Superset custom rules...\n`); |
| |
| files.forEach(file => { |
| // Resolve the file path |
| const resolvedPath = path.resolve(file); |
| if (fs.existsSync(resolvedPath)) { |
| processFile(resolvedPath); |
| } else if (fs.existsSync(file)) { |
| processFile(file); |
| } |
| }); |
| |
| // eslint-disable-next-line no-console |
| console.log(`\n${errorCount} errors, ${warningCount} warnings`); |
| |
| if (errorCount > 0) { |
| process.exit(1); |
| } |
| } |
| |
| // Run if called directly |
| if (require.main === module) { |
| main(); |
| } |
| |
| module.exports = { |
| checkNoLiteralColors, |
| checkNoFaIcons, |
| checkI18nTemplates, |
| checkUntranslatedStrings, |
| }; |