mirror of
https://github.com/elastic/kibana.git
synced 2025-06-28 11:05:39 -04:00
## Summary Adds support for wrapping elements. | Code | Turns into | |--------|--------| | <img width="764" alt="Screenshot 2025-04-01 at 09 25 09" src="https://github.com/user-attachments/assets/9b5d2743-3b61-4d21-b726-0a0be9539d99" /> | <img width="827" alt="Screenshot 2025-04-01 at 09 25 20" src="https://github.com/user-attachments/assets/9879d1cb-e22f-4c80-a666-001b273d6d7d" /> | <img width="744" alt="Screenshot 2025-04-01 at 09 25 54" src="https://github.com/user-attachments/assets/c4320ff8-baa2-4fcc-9b3c-f7ab86c1cb23" /> | <img width="838" alt="Screenshot 2025-04-01 at 09 26 07" src="https://github.com/user-attachments/assets/d81a1232-a643-4775-ac83-a1a97bcbc528" /> | **Message** <img width="804" alt="Screenshot 2025-03-25 at 13 59 36" src="https://github.com/user-attachments/assets/8eaa2f54-aee6-4828-b1d5-15d4d2bfb4c0" /> **Exceptions** If elements have a `aria-label`, `aria-labelledby` or `label`, they are not flagged. **Autofix suggestion** - autofixes are translated with `i18n.translate` - if `i18n` is not imported yet, an import statement is added - If a `placeholder` prop is found, it uses that as the `i18n.translate` default message for `aria-label` - If the element has children, it uses the text value of the children as the default message for the `i18n.translate` default message for `aria-label` --------- Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com> Co-authored-by: Milosz Marcinkowski <38698566+miloszmarcinkowski@users.noreply.github.com>
132 lines
4.5 KiB
TypeScript
132 lines
4.5 KiB
TypeScript
/*
|
|
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
|
* or more contributor license agreements. Licensed under the "Elastic License
|
|
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
|
|
* Public License v 1"; you may not use this file except in compliance with, at
|
|
* your election, the "Elastic License 2.0", the "GNU Affero General Public
|
|
* License v3.0 only", or the "Server Side Public License, v 1".
|
|
*/
|
|
|
|
import { TSESTree } from '@typescript-eslint/typescript-estree';
|
|
|
|
/*
|
|
Attempts to get a string representation of the intent
|
|
out of an array of nodes.
|
|
|
|
Currently supported node types in the array:
|
|
* String literal text (JSXText)
|
|
* Translated text via <FormattedMessage> component -> uses prop `defaultMessage`
|
|
* Translated text via {i18n.translate} call -> uses passed options object key `defaultMessage`
|
|
*/
|
|
export function getIntentFromNode(originalNode: TSESTree.JSXOpeningElement): string {
|
|
if (!originalNode.parent) return '';
|
|
|
|
if (originalNode.parent.type !== 'JSXElement') return '';
|
|
|
|
const { parent } = originalNode;
|
|
|
|
const nodes = Array.isArray(parent.children) ? parent.children : [];
|
|
|
|
if (nodes.length === 0) {
|
|
return '';
|
|
}
|
|
|
|
/*
|
|
In order to satisfy TS we need to do quite a bit of defensive programming.
|
|
This is my best attempt at providing the minimum amount of typeguards and
|
|
keeping the code readable. In the cases where types are explicitly set to
|
|
variables, it was done to help the compiler when it couldn't infer the type.
|
|
*/
|
|
const intent = nodes.reduce((acc: string, currentNode) => {
|
|
switch (currentNode.type) {
|
|
case 'JSXText':
|
|
// When node is a string primitive
|
|
return `${acc}${currentNode.value} `;
|
|
|
|
case 'JSXElement':
|
|
// Determining whether node is of form `<FormattedMessage defaultMessage="message" />`
|
|
const jsxOpeningElement: TSESTree.JSXTagNameExpression = currentNode.openingElement.name;
|
|
const attributes: Array<TSESTree.JSXAttribute | TSESTree.JSXSpreadAttribute> =
|
|
currentNode.openingElement.attributes;
|
|
|
|
if (!('name' in jsxOpeningElement) || jsxOpeningElement.name !== 'FormattedMessage') {
|
|
return '';
|
|
}
|
|
|
|
const defaultMessageProp = attributes.find(
|
|
(attribute) => 'name' in attribute && attribute.name.name === 'defaultMessage'
|
|
);
|
|
|
|
if (
|
|
!defaultMessageProp ||
|
|
!('value' in defaultMessageProp) ||
|
|
!('type' in defaultMessageProp.value!) ||
|
|
defaultMessageProp.value.type !== 'Literal' ||
|
|
typeof defaultMessageProp.value.value !== 'string'
|
|
) {
|
|
return '';
|
|
}
|
|
|
|
return `${acc}${defaultMessageProp.value.value} `;
|
|
|
|
case 'JSXExpressionContainer':
|
|
// Determining whether node is of form `{i18n.translate('foo', { defaultMessage: 'message'})}`
|
|
const expression: TSESTree.JSXEmptyExpression | TSESTree.Expression =
|
|
currentNode.expression;
|
|
|
|
if (!('arguments' in expression)) {
|
|
return '';
|
|
}
|
|
|
|
const args: TSESTree.CallExpressionArgument[] = expression.arguments;
|
|
const callee: TSESTree.LeftHandSideExpression = expression.callee;
|
|
|
|
if (!('object' in callee)) {
|
|
return '';
|
|
}
|
|
|
|
const object: TSESTree.Expression = callee.object;
|
|
const property: TSESTree.Expression | TSESTree.PrivateIdentifier = callee.property;
|
|
|
|
if (!('name' in object) || !('name' in property)) {
|
|
return '';
|
|
}
|
|
|
|
if (object.name !== 'i18n' || property.name !== 'translate') {
|
|
return '';
|
|
}
|
|
|
|
const callExpressionArgument: TSESTree.CallExpressionArgument | undefined = args.find(
|
|
(arg) => arg.type === 'ObjectExpression'
|
|
);
|
|
|
|
if (!callExpressionArgument || callExpressionArgument.type !== 'ObjectExpression') {
|
|
return '';
|
|
}
|
|
|
|
const defaultMessageValue: TSESTree.ObjectLiteralElement | undefined =
|
|
callExpressionArgument.properties.find(
|
|
(prop) =>
|
|
prop.type === 'Property' && 'name' in prop.key && prop.key.name === 'defaultMessage'
|
|
);
|
|
|
|
if (
|
|
!defaultMessageValue ||
|
|
!('value' in defaultMessageValue) ||
|
|
defaultMessageValue.value.type !== 'Literal' ||
|
|
typeof defaultMessageValue.value.value !== 'string'
|
|
) {
|
|
return '';
|
|
}
|
|
|
|
return `${acc}${defaultMessageValue.value.value} `;
|
|
|
|
default:
|
|
break;
|
|
}
|
|
|
|
return acc;
|
|
}, '');
|
|
|
|
return intent.trim();
|
|
}
|