/// /** * The token contract's inline-style check, where a selector cannot reach * (the second review's R9, 25 September). `no-restricted-syntax` reads a * `style={{ … }}` written in place; a style kept in a `const` and passed by name * got through every lint in all three repos: * * const look = { background: 'red' } *
* * This follows the name to its `const` in the same file and reads the keys * there, quoted or not. It reports on the key, where the value is written, so * an escape sits beside it. A style the code works out while it runs passes: * nothing can read it. */ import type { ESLint, Rule, Scope } from 'eslint' /** The style keys outside the token contract: colours, a font size, borders and shadows. */ export const INLINE_STYLE_TOKEN_KEYS = /^(color|background|backgroundColor|backgroundImage|fill|stroke|fontSize|border|borderTop|borderRight|borderBottom|borderLeft|borderBlock|borderInline|borderStyle|boxShadow|textShadow)$|Color$/ export const INLINE_STYLE_MESSAGE = 'An inline style that sets a colour, a font size, a border or a shadow is outside the token contract: use a token class. If the value is computed (a palette, a size from a prop), say why in an eslint-disable comment. Width, height and transforms are fine.' interface Node { type: string [key: string]: unknown } const keyOf = (property: Node): string | undefined => { if (property.computed) return undefined const key = property.key as Node return key.type === 'Identifier' ? (key.name as string) : key.type === 'Literal' && typeof key.value === 'string' ? key.value : undefined } /** The object a `const` of this name holds, in this scope or one around it. */ function constObject(name: string, scope: Scope.Scope | null): Node | undefined { for (let current = scope; current; current = current.upper) { const variable = current.set.get(name) if (!variable) continue const definition = variable.defs[0] as unknown as { type: string; node: Node; parent?: Node } | undefined if (variable.defs.length !== 1 || definition?.type !== 'Variable' || definition.parent?.kind !== 'const') return undefined let init = definition.node.init as Node | undefined while (init && (init.type === 'TSAsExpression' || init.type === 'TSSatisfiesExpression')) init = init.expression as Node return init?.type === 'ObjectExpression' ? init : undefined } return undefined } const noTokenStyle: Rule.RuleModule = { meta: { type: 'problem', docs: { description: 'No colour, font size, border or shadow in a style kept in a const' }, schema: [], messages: { token: INLINE_STYLE_MESSAGE }, }, create(context) { const reported = new Set() return { JSXAttribute(ruleNode: Rule.Node) { const attribute = ruleNode as unknown as Node if ((attribute.name as Node).name !== 'style') return const value = attribute.value as Node | null const expression = value?.type === 'JSXExpressionContainer' ? (value.expression as Node) : undefined if (expression?.type !== 'Identifier') return const object = constObject(expression.name as string, context.sourceCode.getScope(ruleNode)) for (const property of (object?.properties as Node[] | undefined) ?? []) { if (property.type !== 'Property' || reported.has(property)) continue const key = keyOf(property) if (!key || !INLINE_STYLE_TOKEN_KEYS.test(key)) continue reported.add(property) context.report({ node: property as unknown as Rule.Node, messageId: 'token' }) } }, } }, } /** The plugin, as the token lint registers it: `token-style/no-token-style`. */ export const tokenStylePlugin: ESLint.Plugin = { rules: { 'no-token-style': noTokenStyle } }