mirror of
https://github.com/apache/superset.git
synced 2026-04-19 16:14:52 +00:00
fix(i18n): wrap untranslated frontend strings and add i18n lint rule (#37776)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -197,6 +197,357 @@ function checkI18nTemplates(ast, filepath) {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
@@ -214,6 +565,7 @@ function processFile(filepath) {
|
||||
checkNoLiteralColors(ast, filepath);
|
||||
checkNoFaIcons(ast, filepath);
|
||||
checkI18nTemplates(ast, filepath);
|
||||
checkUntranslatedStrings(ast, filepath);
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(
|
||||
@@ -237,6 +589,7 @@ function main() {
|
||||
/\/test\//,
|
||||
/\/tests\//,
|
||||
/\/storybook\//,
|
||||
/^\.storybook\//, // .storybook directory at root
|
||||
/\.stories\./,
|
||||
/\/demo\//,
|
||||
/\/examples\//,
|
||||
@@ -323,4 +676,9 @@ if (require.main === module) {
|
||||
main();
|
||||
}
|
||||
|
||||
module.exports = { checkNoLiteralColors, checkNoFaIcons, checkI18nTemplates };
|
||||
module.exports = {
|
||||
checkNoLiteralColors,
|
||||
checkNoFaIcons,
|
||||
checkI18nTemplates,
|
||||
checkUntranslatedStrings,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user