eslint-plugin-gestalt
Version:
ESLint rules for Pinterest's design language Gestalt
258 lines (236 loc) • 8.79 kB
text/typescript
/**
* @fileoverview Prefer Link: Prevent anchor tags that only contain attributes matching supported props in Gestalt Link
*/
import { renameTagWithPropsFixer, updateGestaltImportFixer } from './helpers/eslintASTFixers';
import {
buildKeyValueTypeArray,
buildProps,
buildValidatorResponsesFromProperties,
hasAriaAttributes,
hasAttributes,
hasDataAttributes,
hasImport,
hasSpreadAttributes,
hasUnsupportedAttributes,
isTag,
} from './helpers/eslintASTHelpers';
import { ESLintRule } from './helpers/eslintFlowTypes';
import preferLinkReducer from './helpers/preferLinkReducer';
export const MESSAGES = {
fixMessageLink: `Use Link from Gestalt (default autofix): <Link href="">Text</Link>\n
OR use TapArea, see suggested options below to autofix\n
OR use Button, <Button role='link' href="" target="" rel="" text=""/>\n
OR use IconButton, <IconButton role='link' href="" target="" rel="" icon=""/>`,
suggestionMessageTapArea: `Use TapArea to provide a Node with navigation behavior: <TapArea role="link" href="" onTap={}>{ Node }</TapArea>.`,
} as const;
const rule: ESLintRule = {
meta: {
type: 'suggestion',
docs: {
description:
'Prefer Link: Prevent anchor tags that only contain attributes matching supported props in Gestalt Link. Use Gestalt Link, instead',
category: 'Gestalt alternatives',
recommended: true,
url: 'https://gestalt.pinterest.systems/eslint%20plugin#gestaltprefer-link',
},
fixable: 'code',
schema: [],
messages: {
fixMessageLink: MESSAGES.fixMessageLink,
suggestionMessageTapArea: MESSAGES.suggestionMessageTapArea,
},
hasSuggestions: true,
},
create(context) {
// @ts-expect-error - TS7034 - Variable 'programNode' implicitly has type 'any' in some locations where its type cannot be determined.
let programNode;
// @ts-expect-error - TS7034 - Variable 'gestaltImportNode' implicitly has type 'any' in some locations where its type cannot be determined.
let gestaltImportNode;
let importFixerRun = false;
// @ts-expect-error - TS7006 - Parameter 'node' implicitly has an 'any' type.
const importDeclarationFnc = (node) => {
if (!node) return;
const isGestaltImportNode = hasImport({
importNode: node,
path: 'gestalt',
});
if (!isGestaltImportNode) return;
gestaltImportNode = node;
};
// @ts-expect-error - TS7006 - Parameter 'node' implicitly has an 'any' type.
const jSXElementFnc = (node) => {
const boxDisallowedAttributes = ['className', 'style'];
const supportedAriaAttributes = ['aria-label', 'aria-selected'];
const supportedEventAttributes = ['onBlur', 'onClick', 'onFocus', 'onKeyPress'];
const supportedLinkAttributes = [
...supportedAriaAttributes,
...supportedEventAttributes,
'href',
'id',
'ref',
'rel',
'target',
];
// First, exit if anchor tag should stay unmodified
if (
!isTag({ elementNode: node.openingElement, tagName: 'a' }) ||
hasSpreadAttributes({ elementNode: node.openingElement }) ||
hasAttributes({
elementNode: node.openingElement,
tagName: 'a',
attributes: boxDisallowedAttributes,
}) ||
hasUnsupportedAttributes({
elementNode: node.openingElement,
tagName: 'a',
supportedAttributes: supportedLinkAttributes,
}) ||
hasAriaAttributes({
elementNode: node.openingElement,
ignoreAttributes: supportedAriaAttributes,
tagName: 'a',
}) ||
hasDataAttributes({
elementNode: node.openingElement,
tagName: 'a',
})
) {
return null;
}
const validatorResponse = buildValidatorResponsesFromProperties({
context,
keyValueTypeArray: buildKeyValueTypeArray({
elementNode: node.openingElement,
nodeType: 'openingElementNode',
}),
reducerCallbackFn: preferLinkReducer,
});
const newPropsToAddToLink = ({ alternativeComponent }: { alternativeComponent: string }) => {
const newResponse =
alternativeComponent === 'Link'
? [...validatorResponse]
: [...validatorResponse, { prop: 'role="link"' }];
switch (alternativeComponent) {
case 'Link':
return newResponse
?.map((alternative) => alternative.prop)
.sort()
.join(' ');
case 'TapArea':
return newResponse
?.map((alternative) => {
if (
typeof alternative.prop === 'string' &&
alternative.prop.startsWith('accessibilitySelected')
) {
return false;
}
if (
typeof alternative.prop === 'string' &&
alternative.prop.startsWith('onKeyPress')
) {
return false;
}
return alternative.prop;
})
.filter(Boolean)
.sort()
.join(' ')
.replace('onClick', 'onTap');
default:
return '';
}
};
// For any other anchor tag modification
return context.report({
node,
messageId: 'fixMessageLink',
// @ts-expect-error - TS7006 - Parameter 'fixer' implicitly has an 'any' type.
fix: (fixer) => {
const tagFixers = renameTagWithPropsFixer({
context,
elementNode: node,
fixer,
// @ts-expect-error - TS7005 - Variable 'gestaltImportNode' implicitly has an 'any' type.
gestaltImportNode,
newComponentName: 'Link',
modifiedPropsString: buildProps({
context,
elementNode: node,
propSorting: false,
propsToAdd: newPropsToAddToLink({ alternativeComponent: 'Link' }),
propsToRemove: [
...supportedAriaAttributes,
...supportedEventAttributes,
'rel',
'target',
],
}),
tagName: 'a',
});
const importFixers = updateGestaltImportFixer({
// @ts-expect-error - TS7005 - Variable 'gestaltImportNode' implicitly has an 'any' type.
gestaltImportNode,
fixer,
newComponentName: 'Link',
// @ts-expect-error - TS7005 - Variable 'programNode' implicitly has an 'any' type.
programNode,
});
const fixers = !importFixerRun ? [...tagFixers, importFixers] : tagFixers;
importFixerRun = true;
return fixers;
},
suggest: [
{
messageId: 'suggestionMessageTapArea',
// @ts-expect-error - TS7006 - Parameter 'fixer' implicitly has an 'any' type.
fix: (fixer) => {
const tagFixers = renameTagWithPropsFixer({
context,
elementNode: node,
fixer,
// @ts-expect-error - TS7005 - Variable 'gestaltImportNode' implicitly has an 'any' type.
gestaltImportNode,
newComponentName: 'TapArea',
modifiedPropsString: buildProps({
context,
elementNode: node,
propSorting: false,
propsToAdd: newPropsToAddToLink({
alternativeComponent: 'TapArea',
}),
propsToRemove: [
...supportedAriaAttributes,
...supportedEventAttributes,
'rel',
'target',
],
}),
tagName: 'a',
});
const importFixers = updateGestaltImportFixer({
// @ts-expect-error - TS7005 - Variable 'gestaltImportNode' implicitly has an 'any' type.
gestaltImportNode,
fixer,
newComponentName: 'TapArea',
// @ts-expect-error - TS7005 - Variable 'programNode' implicitly has an 'any' type.
programNode,
});
const fixers = [...tagFixers, importFixers];
return fixers;
},
},
],
});
};
return {
// @ts-expect-error - TS7006 - Parameter 'node' implicitly has an 'any' type.
Program: (node) => {
programNode = node;
},
ImportDeclaration: importDeclarationFnc,
JSXElement: jSXElementFnc,
};
},
};
export default rule;