The open and composable observability and data visualization platform. Visualize metrics, logs, and traces from multiple sources like Prometheus, Loki, Elasticsearch, InfluxDB, Postgres and many more.
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 
grafana/packages/grafana-eslint-rules/rules/translation-utils.cjs

333 lines
11 KiB

// @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,
};