UNPKG

stylelint

Version:

A mighty CSS linter that helps you avoid errors and enforce conventions.

226 lines (187 loc) 6.02 kB
import { TokenType, isTokenComment, isTokenEOF, isTokenIdent, isTokenWhiteSpaceOrComment, stringify, tokenize, } from '@csstools/css-tokenizer'; import { displayInsideKeywords, displayListItemKeyword, displayOutsideKeywords, } from '../../reference/keywords.mjs'; import { declarationValueIndex } from '../../utils/nodeFieldIndices.mjs'; import getDeclarationValue from '../../utils/getDeclarationValue.mjs'; import isStandardSyntaxValue from '../../utils/isStandardSyntaxValue.mjs'; import { mayIncludeRegexes } from '../../utils/regexes.mjs'; import report from '../../utils/report.mjs'; import ruleMessages from '../../utils/ruleMessages.mjs'; import setDeclarationValue from '../../utils/setDeclarationValue.mjs'; import validateOptions from '../../utils/validateOptions.mjs'; /** @import {CSSToken,TokenIdent} from '@csstools/css-tokenizer' */ const ruleName = 'display-notation'; const messages = ruleMessages(ruleName, { expected: (unexpected, expected) => `Expected "${unexpected}" to be "${expected}"`, }); const meta = { url: 'https://stylelint.io/user-guide/rules/display-notation', fixable: true, }; const DISPLAY_OUTSIDE = new RegExp(`^(?:${[...displayOutsideKeywords.values()].join('|')})$`, 'i'); const DISPLAY_INSIDE = new RegExp(`^(?:${[...displayInsideKeywords.values()].join('|')})$`, 'i'); const DISPLAY_LIST_ITEM = new RegExp( `^(?:${[...displayListItemKeyword.values()].join('|')})$`, 'i', ); const DISPLAY_PROPERTY = /^display$/i; /** @type {Record<string, Array<string>>} */ const SHORT_TO_LONG = { 'block list-item': ['block', 'flow', 'list-item'], block: ['block', 'flow'], flex: ['block', 'flex'], 'flow list-item': ['block', 'flow', 'list-item'], flow: ['block', 'flow'], 'flow-root': ['block', 'flow-root'], grid: ['block', 'grid'], 'inline list-item': ['inline', 'flow', 'list-item'], inline: ['inline', 'flow'], 'inline-block': ['inline', 'flow-root'], 'inline-flex': ['inline', 'flex'], 'inline-grid': ['inline', 'grid'], 'inline-table': ['inline', 'table'], 'list-item': ['block', 'flow', 'list-item'], ruby: ['inline', 'ruby'], 'run-in': ['run-in', 'flow'], table: ['block', 'table'], }; /** @type {Record<string, Array<string>>} */ const LONG_TO_SHORT = { 'block flex': ['flex'], 'block flow list-item': ['list-item'], 'block flow': ['block'], 'block flow-root': ['flow-root'], 'block grid': ['grid'], 'block list-item': ['list-item'], 'block table': ['table'], 'flow list-item': ['list-item'], 'inline flex': ['inline-flex'], 'inline flow list-item': ['inline', 'list-item'], 'inline flow': ['inline'], 'inline flow-root': ['inline-block'], 'inline grid': ['inline-grid'], 'inline ruby': ['ruby'], 'inline table': ['inline-table'], 'run-in flow': ['run-in'], }; /** @type {import('stylelint').CoreRules[ruleName]} */ const rule = (primary) => { return (root, result) => { const validOptions = validateOptions(result, ruleName, { actual: primary, possible: ['short', 'full'], }); if (!validOptions) return; root.walkDecls(DISPLAY_PROPERTY, (decl) => { const value = getDeclarationValue(decl); if (!mayIncludeRegexes.displayKeyword.test(value)) return; if (!isStandardSyntaxValue(value)) return; const tokens = tokenize({ css: value }); // ignore values that contain functions or other non-keyword values const hasNonKeywordTokens = tokens.some( (token) => !(isTokenWhiteSpaceOrComment(token) || isTokenEOF(token) || isTokenIdent(token)), ); if (hasNonKeywordTokens) return; const keywords = tokens.filter(isTokenIdent); if (!keywords.length) return; const normalizedValue = normalizeValue(keywords); const replacementValue = primary === 'short' ? LONG_TO_SHORT[normalizedValue] : SHORT_TO_LONG[normalizedValue]; if (!replacementValue) return; const firstKeyword = keywords.at(0); const lastKeyword = keywords.at(-1); if (!firstKeyword || !lastKeyword) return; const index = declarationValueIndex(decl); report({ message: messages.expected, messageArgs: [normalizedValue, replacementValue.join(' ')], node: decl, index: index + firstKeyword[2], endIndex: index + lastKeyword[3] + 1, result, ruleName, fix: { apply: () => { setDeclarationValue( decl, fixValue(tokens, firstKeyword, lastKeyword, replacementValue), ); }, node: decl, }, }); }); }; }; /** * @param {string} a * @returns {number} */ function keywordOrder(a) { if (DISPLAY_OUTSIDE.test(a)) return 1; if (DISPLAY_INSIDE.test(a)) return 2; if (DISPLAY_LIST_ITEM.test(a)) return 3; return 0; } /** * @param {string} a * @param {string} b * @returns {number} */ function compareKeywords(a, b) { return keywordOrder(a) - keywordOrder(b); } /** * @param {Array<TokenIdent>} tokens * @returns {string} */ function normalizeValue(tokens) { return tokens .map((token) => token[4].value) .sort(compareKeywords) .join(' ') .toLowerCase(); } /** * @param {Array<CSSToken>} originalValue * @param {TokenIdent} firstKeyword * @param {TokenIdent} lastKeyword * @param {Array<string>} replacement * @returns {string} */ function fixValue(originalValue, firstKeyword, lastKeyword, replacement) { const before = originalValue.slice(0, originalValue.indexOf(firstKeyword)); const commentsBetweenKeywords = originalValue .slice(originalValue.indexOf(firstKeyword), originalValue.indexOf(lastKeyword)) .filter(isTokenComment); const after = originalValue.slice(originalValue.indexOf(lastKeyword) + 1); return stringify( ...before, ...replacement.flatMap((keyword, index) => { /** @type {Array<CSSToken>} */ const tokens = []; if (index !== 0) { tokens.push([TokenType.Whitespace, ' ', -1, -1, undefined]); } tokens.push([TokenType.Ident, keyword, -1, -1, { value: keyword }]); return tokens; }), ...commentsBetweenKeywords, ...after, ); } rule.ruleName = ruleName; rule.messages = messages; rule.meta = meta; export default rule;