@atlaskit/eslint-plugin-design-system
Version:
The essential plugin for use with the Atlassian Design System.
227 lines (220 loc) • 8.15 kB
JavaScript
import { isNodeOfType } from 'eslint-codemod-utils';
import renameMapping from '@atlaskit/tokens/rename-mapping';
import tokenDefaultValues from '@atlaskit/tokens/token-default-values';
import tokens from '@atlaskit/tokens/token-names';
import { getTokenId } from '@atlaskit/tokens/utils/get-token-id';
import { createLintRule } from '../utils/create-lint-rule';
import { isDecendantOfStyleBlock } from '../utils/is-decendant-of-style-block';
import { isDecendantOfStyleJsxAttribute } from '../utils/is-decendant-of-style-jsx-attribute';
import { isToken } from '../utils/is-token';
const rule = createLintRule({
meta: {
name: 'no-unsafe-design-token-usage',
docs: {
description: 'Enforces design token usage is statically and locally analyzable.',
recommended: true,
severity: 'error'
},
fixable: 'code',
type: 'problem',
messages: {
directTokenUsage: `Access the global theme using the token function.
\`\`\`
import { token } from '@atlaskit/tokens';
token('{{tokenKey}}');
\`\`\`
`,
staticToken: `Token string should be inlined directly into the function call.
\`\`\`
token('color.background.blanket');
\`\`\`
`,
invalidToken: 'The token "{{name}}" does not exist.',
tokenRemoved: 'The token "{{name}}" is removed in favour of "{{replacement}}".',
tokenIsExperimental: 'The token "{{name}}" is experimental and should not be used directly at this time. It should be replaced by "{{replacement}}".',
tokenFallbackEnforced: `Token function requires a fallback, preferably something that best matches the light/default theme in case tokens aren't present.
\`\`\`
token('color.background.blanket', N500A);
\`\`\`
`,
tokenFallbackRestricted: `Token function must not use a fallback.
\`\`\`
token('color.background.blanket');
\`\`\`
`
},
schema: [{
type: 'object',
properties: {
shouldEnforceFallbacks: {
type: 'boolean'
},
fallbackUsage: {
enum: ['forced', 'optional', 'none']
},
UNSAFE_ignoreTokens: {
type: 'array',
items: {
type: 'string'
}
}
}
}]
},
create(context) {
// TODO: JFP-2823 - this type cast was added due to Jira's ESLint v9 migration
const config = {
...context.options[0]
};
if (!config.fallbackUsage) {
config.fallbackUsage = config.shouldEnforceFallbacks ? 'forced' : 'none';
}
const UNSAFE_ignoreTokens = new Set(config.UNSAFE_ignoreTokens);
return {
'TaggedTemplateExpression[tag.name="css"],TaggedTemplateExpression[tag.object.name="styled"]': node => {
if (!isNodeOfType(node, 'TaggedTemplateExpression')) {
return;
}
const value = node.quasi.quasis.map(q => q.value.raw).join('');
const tokenKey = isToken(value, tokens);
if (tokenKey) {
context.report({
messageId: 'directTokenUsage',
node,
data: {
tokenKey
}
});
return;
}
},
'ObjectExpression > Property > Literal': node => {
if (node.type !== 'Literal') {
return;
}
if (typeof node.value !== 'string') {
return;
}
if (!isDecendantOfStyleBlock(node) && !isDecendantOfStyleJsxAttribute(node)) {
return;
}
const tokenKey = isToken(node.value, tokens);
const isCSSVar = node.value.startsWith('var(');
if (tokenKey) {
context.report({
messageId: 'directTokenUsage',
node,
data: {
tokenKey
},
fix: fixer => isCSSVar ? fixer.replaceText(node, `token('${tokenKey}')`) : null
});
return;
}
},
'CallExpression:matches([callee.name="token"], [callee.name="getTokenValue"])': node => {
if (!isNodeOfType(node, 'CallExpression')) {
return;
}
const isGetTokenValueCall = isNodeOfType(node.callee, 'Identifier') && node.callee.name === 'getTokenValue';
// Skip processing if it's a `getTokenValue` call and config.fallbackUsage is `none`
if (isGetTokenValueCall && config.fallbackUsage === 'none') {
return;
}
if (node.arguments.length < 2 && config.fallbackUsage === 'forced') {
let fix;
if (node.arguments[0].type === 'Literal') {
const {
value
} = node.arguments[0];
const tokenName = value;
const fallbackValue = tokenDefaultValues[tokenName] || null;
if (fallbackValue) {
fix = fixer => fixer.replaceText(node, `${isNodeOfType(node.callee, 'Identifier') ? node.callee.name : 'token'}('${tokenName}', '${fallbackValue}')`);
}
}
context.report({
messageId: 'tokenFallbackEnforced',
node,
fix
});
} else if (node.arguments.length > 1 && config.fallbackUsage === 'none') {
if (node.arguments[0].type === 'Literal') {
const {
value
} = node.arguments[0];
/**
* This check allows ADS tokens to be passed in via UNSAFE_ignoreTokens and allow fallbacks even if `fallbackUsage` is set to 'none'.
* Temporary solution introduced to allow fallbacks for shape tokens in Jira during migration
*/
if (value && UNSAFE_ignoreTokens.has(value)) {
return;
}
context.report({
messageId: 'tokenFallbackRestricted',
node: node.arguments[1],
fix: fixer => fixer.replaceText(node, `${isNodeOfType(node.callee, 'Identifier') ? node.callee.name : 'token'}('${value}')`)
});
} else {
context.report({
messageId: 'tokenFallbackRestricted',
node: node.arguments[1]
});
}
}
if (node.arguments[0] && node.arguments[0].type !== 'Literal') {
context.report({
messageId: 'staticToken',
node
});
return;
}
const tokenKey = node.arguments[0].value;
if (!tokenKey) {
return;
}
const deletedMigrationMeta = renameMapping.filter(t => t.state === 'deleted').find(t => getTokenId(t.path) === tokenKey);
if (typeof tokenKey === 'string' && deletedMigrationMeta && deletedMigrationMeta.replacement) {
const cleanTokenKey = getTokenId(deletedMigrationMeta.replacement);
context.report({
messageId: 'tokenRemoved',
node,
data: {
name: tokenKey,
replacement: cleanTokenKey
},
fix: fixer => fixer.replaceText(node.arguments[0], `'${cleanTokenKey}'`)
});
return;
}
const tokenMeta = renameMapping.filter(t => t.state === 'experimental').find(t => getTokenId(t.path) === tokenKey);
const tokenNames = Object.keys(tokens);
if (typeof tokenKey === 'string' && tokenMeta && tokenMeta.replacement) {
const replacementValue = tokenMeta.replacement;
const isReplacementAToken = tokenNames.includes(replacementValue);
context.report({
messageId: 'tokenIsExperimental',
node,
data: {
name: tokenKey,
replacement: replacementValue
},
fix: fixer => isReplacementAToken ? fixer.replaceText(node.arguments[0], `'${replacementValue}'`) : fixer.replaceText(node, `'${replacementValue}'`)
});
return;
}
if (typeof tokenKey !== 'string' || typeof tokenKey === 'string' && !tokens[tokenKey] && !UNSAFE_ignoreTokens.has(tokenKey)) {
context.report({
messageId: 'invalidToken',
node,
data: {
name: tokenKey.toString()
}
});
return;
}
}
};
}
});
export default rule;