Files
grafana/packages/grafana-eslint-rules/rules/translation-utils.cjs

334 lines
11 KiB
JavaScript

// @ts-check
/** @typedef {import('@typescript-eslint/utils').TSESTree.Node} Node */
/** @typedef {import('@typescript-eslint/utils').TSESTree.JSXAttribute} JSXAttribute */
/** @typedef {import('@typescript-eslint/utils').TSESTree.JSXElement} JSXElement */
/** @typedef {import('@typescript-eslint/utils').TSESTree.JSXFragment} JSXFragment */
/** @typedef {import('@typescript-eslint/utils').TSESTree.JSXText} JSXText */
/** @typedef {import('@typescript-eslint/utils').TSESTree.JSXChild} JSXChild */
/** @typedef {import('@typescript-eslint/utils/ts-eslint').RuleFixer} RuleFixer */
/** @typedef {import('@typescript-eslint/utils/ts-eslint').RuleContext<'noUntranslatedStrings' | 'noUntranslatedStringsProp' | 'wrapWithTrans' | 'wrapWithT', [{forceFix: string[]}]>} RuleContextWithOptions */
const { AST_NODE_TYPES } = require('@typescript-eslint/utils');
/**
* @param {Node} node
*/
const elementIsTrans = (node) => {
return (
node.type === AST_NODE_TYPES.JSXElement &&
node.openingElement.type === AST_NODE_TYPES.JSXOpeningElement &&
node.openingElement.name.type === AST_NODE_TYPES.JSXIdentifier &&
node.openingElement.name.name === 'Trans'
);
};
/**
* @param {Node} node
*/
const isStringLiteral = (node) => {
return node.type === AST_NODE_TYPES.Literal && typeof node.value === 'string';
};
/**
* Converts a string to kebab case
* @param {string} str The string to convert
* @returns {string} The kebab case string
*/
function toKebabCase(str) {
return str
.replace(/([a-z])([A-Z])/g, '$1-$2')
.toLowerCase()
.replace(/\s+/g, '-');
}
/**
* Checks if a string is non-alphanumeric
* @param {string} str The string to check
* @returns {boolean}
*/
function isStringNonAlphanumeric(str) {
return !/[a-zA-Z0-9]/.test(str);
}
/**
* Checks if we _should_ fix an error automatically
* @param {RuleContextWithOptions} context
* @returns {boolean} Whether the node should be fixed
*/
function shouldBeFixed(context) {
const pathsThatAreFixable = context.options[0]?.forceFix || [];
return pathsThatAreFixable.some((path) => context.filename.includes(path));
}
/**
* Checks if a node can be fixed automatically
* @param {JSXAttribute|JSXElement|JSXFragment} node The node to check
* @param {RuleContextWithOptions} context
* @returns {boolean} Whether the node can be fixed
*/
function canBeFixed(node, context) {
if (!getTranslationPrefix(context)) {
return false;
}
// We can only fix JSX attribute strings that are within a function,
// otherwise the `t` function call will be made too early
if (node.type === AST_NODE_TYPES.JSXAttribute) {
const ancestors = context.sourceCode.getAncestors(node);
const isInFunction = ancestors.some((anc) => {
return [
AST_NODE_TYPES.ArrowFunctionExpression,
AST_NODE_TYPES.FunctionDeclaration,
AST_NODE_TYPES.ClassDeclaration,
].includes(anc.type);
});
if (!isInFunction) {
return false;
}
if (node.value?.type === AST_NODE_TYPES.JSXExpressionContainer) {
return isStringLiteral(node.value.expression);
}
}
const values =
node.type === AST_NODE_TYPES.JSXElement || node.type === AST_NODE_TYPES.JSXFragment
? node.children.map((child) => {
return getNodeValue(child);
})
: [getNodeValue(node)];
const stringIsTooLong = values.some((value) => value.trim().split(' ').length > 10);
// If we have more than 10 words,
// we don't want to fix it automatically as the chance of a duplicate key is higher,
// and it's better for a user to manually decide the key
if (stringIsTooLong) {
return false;
}
const stringIsNonAlphanumeric = values.some((value) => !/[a-zA-Z0-9]/.test(value));
const stringContainsHTMLEntities = values.some((value) => /(&[a-zA-Z0-9]+;)/.test(value));
// If node only contains non-alphanumeric characters,
// or contains HTML character entities, then we don't want to autofix
if (stringIsNonAlphanumeric || stringContainsHTMLEntities) {
return false;
}
return true;
}
/**
* Gets the translation prefix from the filename
* @param {RuleContextWithOptions} context
* @returns {string|null} The translation prefix or null
*/
function getTranslationPrefix(context) {
const filename = context.getFilename();
const match = filename.match(/public\/app\/features\/([^/]+)/);
if (match) {
return match[1];
}
return null;
}
/**
* Gets the i18n key for a node
* @param {JSXAttribute|JSXText} node The node
* @param {RuleContextWithOptions} context
* @returns {string} The i18n key
*/
const getI18nKey = (node, context) => {
const prefixFromFilePath = getTranslationPrefix(context);
const stringValue = getNodeValue(node);
const componentNames = getComponentNames(node, context);
const words = stringValue
.trim()
.replace(/[^\a-zA-Z\s]/g, '')
.trim()
.split(/\s+/);
const maxWordsForKey = 6;
// If we have more than 6 words, filter out the words that are less than 4 characters
// This heuristic tends to result in a good balance between unique and descriptive keys
const filteredWords = words.length > maxWordsForKey ? words.filter((word) => word.length > 4) : words;
// If we've filtered everything out, use the original words, deduplicated
const wordsToUse = filteredWords.length === 0 ? words : filteredWords;
const uniqueWords = [...new Set(wordsToUse)].slice(0, maxWordsForKey);
let kebabString = toKebabCase(uniqueWords.join(' '));
if (node.type === AST_NODE_TYPES.JSXAttribute) {
const propName = node.name.name;
const attribute = node.parent?.attributes.find(
(attr) =>
attr.type === AST_NODE_TYPES.JSXAttribute &&
attr.name.type === AST_NODE_TYPES.JSXIdentifier &&
attr &&
['id', 'data-testid'].includes(attr.name?.name)
);
const potentialId =
attribute &&
attribute.type === AST_NODE_TYPES.JSXAttribute &&
attribute.value &&
attribute.value.type === AST_NODE_TYPES.Literal
? attribute.value.value
: undefined;
kebabString = [potentialId, propName, kebabString].filter(Boolean).join('-');
}
const fullPrefix = [prefixFromFilePath, ...componentNames, kebabString].filter(Boolean).join('.');
return fullPrefix;
};
/**
* Gets component names from ancestors
* @param {JSXAttribute|JSXText} node The node
* @param {RuleContextWithOptions} context
* @returns {string[]} The component names
*/
function getComponentNames(node, context) {
const names = [];
const ancestors = context.sourceCode.getAncestors(node);
for (const ancestor of ancestors) {
if (
ancestor.type === AST_NODE_TYPES.VariableDeclarator ||
ancestor.type === AST_NODE_TYPES.FunctionDeclaration ||
ancestor.type === AST_NODE_TYPES.ClassDeclaration
) {
const name = ancestor.id?.type === AST_NODE_TYPES.Identifier ? ancestor.id.name : '';
// Remove the word "component" from the name, as this is a bit
// redundant in a translation key
const sanitizedName = name.replace(/component/gi, '');
names.push(toKebabCase(sanitizedName));
}
}
return names;
}
/**
* Gets the import fixer for a node
* @param {JSXElement|JSXFragment|JSXAttribute} node
* @param {RuleFixer} fixer The fixer
* @param {string} importName The import name
* @param {RuleContextWithOptions} context
* @returns {import('@typescript-eslint/utils/ts-eslint').RuleFix|undefined} The fix
*/
function getImportsFixer(node, fixer, importName, context) {
const body = context.sourceCode.ast.body;
const existingAppCoreI18n = body.find(
(node) => node.type === AST_NODE_TYPES.ImportDeclaration && node.source.value === 'app/core/internationalization'
);
// If there's no existing import at all, add it
if (!existingAppCoreI18n) {
return fixer.insertTextBefore(body[0], `import { ${importName} } from 'app/core/internationalization';\n`);
}
// To keep the typechecker happy - we have to explicitly check the type
// so we can infer it further down
if (existingAppCoreI18n.type !== AST_NODE_TYPES.ImportDeclaration) {
return;
}
// If there's an existing import, and it already has the importName, do nothing
if (
existingAppCoreI18n.specifiers.some((s) => {
return (
s.type === AST_NODE_TYPES.ImportSpecifier &&
s.imported.type === AST_NODE_TYPES.Identifier &&
s.imported.name === importName
);
})
) {
return;
}
const lastSpecifier = existingAppCoreI18n.specifiers[existingAppCoreI18n.specifiers.length - 1];
/** @type {[number, number]} */
const range = [lastSpecifier.range[1], lastSpecifier.range[1]];
return fixer.insertTextAfterRange(range, `, ${importName}`);
}
/**
* @param {JSXElement|JSXFragment} node
* @param {RuleContextWithOptions} context
* @returns {(fixer: RuleFixer) => import('@typescript-eslint/utils/ts-eslint').RuleFix[]}
*/
const getTransFixers = (node, context) => (fixer) => {
const fixes = [];
const children = node.children;
children.forEach((child) => {
if (child.type === AST_NODE_TYPES.JSXText) {
const i18nKey = getI18nKey(child, context);
const value = getNodeValue(child);
fixes.push(fixer.replaceText(child, `<Trans i18nKey="${i18nKey}">${value}</Trans>`));
}
});
const importsFixer = getImportsFixer(node, fixer, 'Trans', context);
if (importsFixer) {
fixes.push(importsFixer);
}
return fixes;
};
/**
* @param {JSXAttribute} node
* @param {RuleContextWithOptions} context
* @returns {(fixer: RuleFixer) => import('@typescript-eslint/utils/ts-eslint').RuleFix[]}
*/
const getTFixers = (node, context) => (fixer) => {
const fixes = [];
const i18nKey = getI18nKey(node, context);
const value = getNodeValue(node);
const wrappingQuotes = value.includes('"') ? "'" : '"';
fixes.push(
fixer.replaceText(node, `${node.name.name}={t("${i18nKey}", ${wrappingQuotes}${value}${wrappingQuotes})}`)
);
const importsFixer = getImportsFixer(node, fixer, 't', context);
if (importsFixer) {
fixes.push(importsFixer);
}
return fixes;
};
/**
* Gets the value of a node
* @param {JSXAttribute|JSXText|JSXElement|JSXFragment|JSXChild} node The node
* @returns {string} The node value
*/
function getNodeValue(node) {
if (node.type === AST_NODE_TYPES.JSXAttribute && node.value?.type === AST_NODE_TYPES.Literal) {
return String(node.value.value) || '';
}
if (node.type === AST_NODE_TYPES.JSXText) {
// Return the raw value if we can, so we can work out if there are any HTML entities
return node.raw;
}
if (node.type === AST_NODE_TYPES.JSXAttribute && node.value?.type === AST_NODE_TYPES.JSXExpressionContainer) {
// this condition is basically `isStringLiteral`, but we can't use the function
// else it doesn't narrow the type correctly :(
if (node.value.expression.type === AST_NODE_TYPES.Literal && typeof node.value.expression.value === 'string') {
return node.value.expression.value;
}
}
return '';
}
module.exports = {
getNodeValue,
getTFixers,
getTransFixers,
getTranslationPrefix,
canBeFixed,
shouldBeFixed,
elementIsTrans,
isStringNonAlphanumeric,
};