@atlaskit/eslint-plugin-ui-styling-standard
Version:
The eslint plugin to enforce and educate on Atlassian's UI Styling Standard
227 lines • 10.8 kB
JavaScript
import { getSourceCode } from '@atlaskit/eslint-utils/context-compat';
import { importSources } from '@atlaskit/eslint-utils/schema';
import { createLintRuleWithTypedConfig } from '../utils/create-rule-with-typed-config';
import { isSimpleSelector } from '../utils/is-simple-selector';
import { parseSelector } from '../utils/parse-selector';
import { getStyleCalls } from '../utils/style-calls';
import { walkStyleCallProperties } from '../utils/walk-style-call-properties';
import { allowedPseudos } from './constants';
import { lintSelector } from './lint-selector';
import { walkCssMapCall } from './walk-css-map';
const ignoredAtRules = new Set(['@container',
// ignored because it's covered by `no-container-queries`
'@media',
// ignored because it's covered by `no-nested-styles`
'@supports', '@property', '@starting-style']);
const canonicalMediaQueries = new Map([['media-above-xxs', '@media all'], ['media-above-xs', '@media (min-width: 30rem)'], ['media-above-sm', '@media (min-width: 48rem)'], ['media-above-md', '@media (min-width: 64rem)'], ['media-above-lg', '@media (min-width: 90rem)'], ['media-above-xl', '@media (min-width: 110.5rem)'], ['media-below-xs', '@media not all and (min-width: 30rem)'], ['media-below-sm', '@media not all and (min-width: 48rem)'], ['media-below-md', '@media not all and (min-width: 64rem)'], ['media-below-lg', '@media not all and (min-width: 90rem)'], ['media-below-xl', '@media not all and (min-width: 110.5rem)'], ['media-only-xxs', '@media (min-width: 0rem) and (max-width: 29.99rem)'], ['media-only-xs', '@media (min-width: 30rem) and (max-width: 47.99rem)'], ['media-only-sm', '@media (min-width: 48rem) and (max-width: 63.99rem)'], ['media-only-md', '@media (min-width: 64rem) and (max-width: 89.99rem)'], ['media-only-lg', '@media (min-width: 90rem) and (max-width: 110.49rem)'], ['media-only-xl', '@media (min-width: 110.5rem)'], ['media-dark-mode', '@media (prefers-color-scheme: dark)'], ['media-light-mode', '@media (prefers-color-scheme: light)'], ['media-reduced-motion', '@media (prefers-reduced-motion: reduce)'], ['media-reduced-transparency', '@media (prefers-reduced-transparency: reduce)'], ['media-forced-colors-active', '@media screen and (forced-colors: active)'], ['media-legacy-high-contrast', '@media screen and (-ms-high-contrast: active)'], ['media-forced-colors-or-legacy-high-contrast', '@media screen and (forced-colors: active), screen and (-ms-high-contrast: active)']]);
const unwrapTypeScriptExpression = node => {
let expression = node;
while (expression.type === 'TSAsExpression' || expression.type === 'TSTypeAssertion' || expression.type === 'TSNonNullExpression' || expression.type === 'TSSatisfiesExpression') {
expression = expression.expression;
}
return expression;
};
const isCanonicalTypedMediaKey = (node, importedMediaTypes) => {
if (node.type !== 'TSSatisfiesExpression') {
return false;
}
const expression = unwrapTypeScriptExpression(node);
if (expression.type !== 'Literal' || typeof expression.value !== 'string') {
return false;
}
let satisfiesExpression = node;
while (((_satisfiesExpression = satisfiesExpression) === null || _satisfiesExpression === void 0 ? void 0 : _satisfiesExpression.type) === 'TSSatisfiesExpression') {
var _satisfiesExpression, _satisfiesExpression$, _satisfiesExpression$2, _satisfiesExpression$3;
const typeName = (_satisfiesExpression$ = satisfiesExpression.typeAnnotation) === null || _satisfiesExpression$ === void 0 ? void 0 : (_satisfiesExpression$2 = _satisfiesExpression$.typeName) === null || _satisfiesExpression$2 === void 0 ? void 0 : _satisfiesExpression$2.name;
if (((_satisfiesExpression$3 = satisfiesExpression.typeAnnotation) === null || _satisfiesExpression$3 === void 0 ? void 0 : _satisfiesExpression$3.type) === 'TSTypeReference' && typeName) {
const source = importedMediaTypes.get(typeName);
if (source && canonicalMediaQueries.get(source) === expression.value) {
return true;
}
}
satisfiesExpression = satisfiesExpression.expression;
}
return false;
};
const getImportedMediaTypeSources = context => {
const importedMediaTypes = new Map();
for (const statement of getSourceCode(context).ast.body) {
if (statement.type !== 'ImportDeclaration' || typeof statement.source.value !== 'string') {
continue;
}
const match = statement.source.value.match(/^@atlaskit\/css\/at-rules\/(media-[a-z-]+)$/);
if (match) {
for (const specifier of statement.specifiers) {
if (specifier.type === 'ImportDefaultSpecifier') {
importedMediaTypes.set(specifier.local.name, match[1]);
}
}
}
}
return importedMediaTypes;
};
const rule = createLintRuleWithTypedConfig({
meta: {
name: 'no-unsafe-selectors',
docs: {
description: 'Disallows use of nested styles in `css` functions.',
recommended: true,
severity: 'error'
},
fixable: 'code',
hasSuggestions: true,
messages: {
/**
* Selector messages
*/
'no-ambiguous-pseudos': "Prefix the pseudo-selector with & when it targets this element (for example &:hover); apply descendant styles directly to the child instead.",
'no-increased-specificity': "Replace chained nesting selectors such as && with one & selector, then resolve competing styles through local composition.",
'no-restricted-pseudos': "The {{pseudo}} pseudo-selector is unsupported. Use an allowed local pseudo such as &:hover, or pass explicit state as a prop and select a local cssMap variant.",
'no-legacy-pseudo-element-syntax': "Use a double colon for pseudo-elements, for example &::before or &::after, instead of the legacy single-colon syntax.",
'no-unparsable-selectors': "The selector '{{selectorText}}' could not be parsed. Correct its CSS syntax; if it is valid, report the selector and reproduction to #help-ui-styling-standard.",
'no-selectors-object': "Move selectors out of the selectors object into supported local style keys; apply child styles directly to each child element.",
/**
* At-rule messages
*/
'no-keyframes-at-rules': "Replace the @keyframes at-rule with keyframes() from @compiled/react and reference its result through animationName.",
'no-restricted-at-rules': "{{atRule}} is unsupported. Use a supported @media, @supports or @property rule; for runtime conditions select a local style variant in JavaScript.",
'no-grouped-at-rules': "Flatten grouped at-rules into complete keys, for example \"@media (min-width: 48rem)\" instead of an \"@media\" object containing conditions.",
'no-noncanonical-media-query': "Match the literal query to its imported @atlaskit/css/at-rules type, including the exact breakpoint, units and comparison operator.",
/**
* Suggestion messages
*/
'insert-nesting-selector': "Insert & before this pseudo-selector to target the element itself, for example &:hover."
},
schema: {
type: 'object',
properties: {
importSources,
shouldAlwaysInsertNestingSelectorForAmbiguousPseudos: {
type: 'boolean',
default: true
}
},
additionalProperties: false
}
},
create(context, config) {
return {
Program() {
const importedMediaTypes = getImportedMediaTypeSources(context);
for (const styleCall of getStyleCalls(context)) {
if (!config.importSources.includes(styleCall.importSource)) {
continue;
}
if (styleCall.styleFunction === 'cssMap') {
walkCssMapCall(styleCall.node, property => {
const {
type,
node
} = property;
if (type === 'grouped-at-rules') {
context.report({
node: node.key,
messageId: 'no-grouped-at-rules'
});
} else if (type === 'selectors') {
context.report({
node: node.key,
messageId: 'no-selectors-object'
});
}
});
}
walkStyleCallProperties(styleCall, ({
key,
value
}) => {
const keyType = key.type;
if (value.type !== 'ObjectExpression' || keyType !== 'Literal' && keyType !== 'TSSatisfiesExpression') {
return;
}
if (keyType === 'TSSatisfiesExpression') {
const unwrappedKey = unwrapTypeScriptExpression(key);
if (unwrappedKey.type === 'Literal' && typeof unwrappedKey.value === 'string') {
if (unwrappedKey.value.startsWith('@media') && !isCanonicalTypedMediaKey(key, importedMediaTypes)) {
context.report({
node: key,
messageId: 'no-noncanonical-media-query'
});
}
return;
}
return;
}
const selectorText = key.value;
if (typeof selectorText !== 'string') {
return;
}
if (selectorText.includes('@')) {
lintAtRule({
context,
sourceNode: key,
atRule: selectorText
});
return;
}
if (isSimpleSelector(selectorText, {
allowedPseudos,
allowLeadingPseudo: styleCall.styleFunction === 'xcss'
})) {
return;
}
try {
const selectorList = parseSelector(context, selectorText);
for (const selector of selectorList.nodes) {
lintSelector({
context,
sourceNode: key,
selector,
config,
isXcssCall: styleCall.styleFunction === 'xcss'
});
}
} catch {
context.report({
node: key,
messageId: 'no-unparsable-selectors',
data: {
selectorText
}
});
}
});
}
}
};
}
});
export default rule;
function lintAtRule({
context,
sourceNode,
atRule
}) {
const matches = atRule.trim().match(/^@[A-z-]+/);
if (!matches) {
return;
}
const ruleName = matches[0];
if (ignoredAtRules.has(ruleName)) {
return;
}
if (ruleName === '@keyframes') {
context.report({
node: sourceNode,
messageId: 'no-keyframes-at-rules'
});
} else {
context.report({
node: sourceNode,
messageId: 'no-restricted-at-rules',
data: {
atRule: ruleName
}
});
}
}