stylelint
Version:
A mighty CSS linter that helps you avoid errors and enforce conventions.
309 lines (245 loc) • 8.89 kB
JavaScript
import valueParser from 'postcss-value-parser';
import {
flowRelativeProperties,
physicalToFlowRelativeProperties,
} from '../../reference/properties.mjs';
import { isAtRule, isValueWord } from '../../utils/typeGuards.mjs';
import { isRegExp, isString } from '../../utils/validateTypes.mjs';
import { declarationValueIndex } from '../../utils/nodeFieldIndices.mjs';
import directionFlowToSides from '../../utils/directionFlowToSides.mjs';
import findNodeUpToRoot from '../../utils/findNodeUpToRoot.mjs';
import getDeclarationValue from '../../utils/getDeclarationValue.mjs';
import isCustomProperty from '../../utils/isCustomProperty.mjs';
import isStandardSyntaxProperty from '../../utils/isStandardSyntaxProperty.mjs';
import optionsMatches from '../../utils/optionsMatches.mjs';
import { propertyRegexes } 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';
const ruleName = 'property-layout-mappings';
const messages = ruleMessages(ruleName, {
rejected: (type, property) => `Disallowed ${type} property "${property}"`,
expected: (actual, expected) => `Expected "${actual}" to be "${expected}"`,
});
const meta = {
url: 'https://stylelint.io/user-guide/rules/property-layout-mappings',
fixable: true,
};
/** @param {import('postcss').Node} node */
function isPageAtRule(node) {
return isAtRule(node) && node.name.toLowerCase() === 'page';
}
/** @import {Directionality} from 'stylelint' */
/**
* Builds a physical → flow-relative property map based on the directionality config.
*
* @param {{ block: Directionality, inline: Directionality }} directionality
* @returns {Map<string, string>}
*/
function buildDirectionalMap(directionality) {
const [blockStart, blockEnd] = directionFlowToSides(directionality.block);
const [inlineStart, inlineEnd] = directionFlowToSides(directionality.inline);
const inlineIsHorizontal =
directionality.inline === 'left-to-right' || directionality.inline === 'right-to-left';
/** @type {Record<string, string>} */
const sideToLogical = {
[blockStart]: 'block-start',
[blockEnd]: 'block-end',
[inlineStart]: 'inline-start',
[inlineEnd]: 'inline-end',
};
/** @type {Map<string, string>} */
const result = new Map();
for (const [physical, defaultLogical] of physicalToFlowRelativeProperties) {
// Border radius properties (two physical sides → block + inline positions)
if (physical.endsWith('-radius')) {
const verticalSide = physical.includes('top') ? 'top' : 'bottom';
const horizontalSide = physical.includes('left') ? 'left' : 'right';
if (inlineIsHorizontal) {
// Vertical sides are block, horizontal sides are inline
const blockPosition = verticalSide === blockStart ? 'start' : 'end';
const inlinePosition = horizontalSide === inlineStart ? 'start' : 'end';
result.set(physical, `border-${blockPosition}-${inlinePosition}-radius`);
} else {
// Horizontal sides are block, vertical sides are inline
const blockPosition = horizontalSide === blockStart ? 'start' : 'end';
const inlinePosition = verticalSide === inlineStart ? 'start' : 'end';
result.set(physical, `border-${blockPosition}-${inlinePosition}-radius`);
}
continue;
}
// Size properties (width/height ↔ inline-size/block-size)
if (physical.includes('width') || physical.includes('height')) {
if (inlineIsHorizontal) {
result.set(physical, defaultLogical);
} else {
const swapped = defaultLogical.includes('inline')
? defaultLogical.replace('inline', 'block')
: defaultLogical.replace('block', 'inline');
result.set(physical, swapped);
}
continue;
}
// Axis properties (overflow-x/y, overscroll-behavior-x/y)
if (physical.endsWith('-x') || physical.endsWith('-y')) {
if (inlineIsHorizontal) {
result.set(physical, defaultLogical);
} else {
const swapped = defaultLogical.includes('inline')
? defaultLogical.replace('inline', 'block')
: defaultLogical.replace('block', 'inline');
result.set(physical, swapped);
}
continue;
}
// Side properties (margin-left, padding-top, left, etc.)
/** @type {string | undefined} */
let physicalSide;
for (const side of ['left', 'right', 'top', 'bottom']) {
if (physical === side || physical.endsWith(`-${side}`) || physical.includes(`-${side}-`)) {
physicalSide = side;
break;
}
}
if (!physicalSide) continue;
const logicalPosition = sideToLogical[physicalSide];
if (!logicalPosition) continue;
let logicalProp = defaultLogical;
if (defaultLogical.includes('inline-start')) {
logicalProp = defaultLogical.replace('inline-start', logicalPosition);
} else if (defaultLogical.includes('inline-end')) {
logicalProp = defaultLogical.replace('inline-end', logicalPosition);
} else if (defaultLogical.includes('block-start')) {
logicalProp = defaultLogical.replace('block-start', logicalPosition);
} else if (defaultLogical.includes('block-end')) {
logicalProp = defaultLogical.replace('block-end', logicalPosition);
}
result.set(physical, logicalProp);
}
return result;
}
/** @type {import('stylelint').CoreRules[ruleName]} */
const rule = (primary, secondaryOptions) => {
return (root, result) => {
const validOptions = validateOptions(
result,
ruleName,
{
actual: primary,
possible: ['flow-relative', 'physical'],
},
{
actual: secondaryOptions,
possible: {
ignoreProperties: [isString, isRegExp],
},
optional: true,
},
);
if (!validOptions) return;
const directionality = result.stylelint.config?.languageOptions?.directionality;
const block = directionality?.block;
const inline = directionality?.inline;
const directionalMap = block && inline ? buildDirectionalMap({ block, inline }) : null;
/**
* @param {string} type
* @param {string} name
* @param {import('postcss').Declaration} decl
* @param {number} index
* @param {number} endIndex
*/
function complain(type, name, decl, index, endIndex) {
report({
message: messages.rejected,
messageArgs: [type, name],
node: decl,
result,
ruleName,
index,
endIndex,
});
}
root.walkDecls((decl) => {
const { prop } = decl;
if (!isStandardSyntaxProperty(prop)) return;
if (isCustomProperty(prop)) return;
if (optionsMatches(secondaryOptions, 'ignoreProperties', prop)) return;
const index = 0;
const endIndex = index + prop.length;
if (primary === 'physical') {
if (!flowRelativeProperties.has(prop)) return;
if (findNodeUpToRoot(decl, isPageAtRule)) return;
complain('flow-relative', prop, decl, index, endIndex);
} else {
if (!physicalToFlowRelativeProperties.has(prop)) return;
if (findNodeUpToRoot(decl, isPageAtRule)) return;
if (!directionalMap) {
complain('physical', prop, decl, index, endIndex);
return;
}
const expectedProp = directionalMap.get(prop);
if (!expectedProp) return;
report({
message: messages.expected,
messageArgs: [prop, expectedProp],
index,
endIndex,
node: decl,
result,
ruleName,
fix: {
apply: () => {
decl.prop = expectedProp;
},
node: decl,
},
});
}
});
root.walkDecls(propertyRegexes.propertyAcceptingNames, (decl) => {
if (optionsMatches(secondaryOptions, 'ignoreProperties', decl.prop)) return;
const parsedValue = valueParser(getDeclarationValue(decl));
const baseIndex = declarationValueIndex(decl);
parsedValue.walk((node) => {
if (!isValueWord(node)) return;
const { value: nodeValue, sourceIndex } = node;
if (optionsMatches(secondaryOptions, 'ignoreProperties', nodeValue)) return;
const index = baseIndex + sourceIndex;
const endIndex = index + nodeValue.length;
if (primary === 'physical') {
if (!flowRelativeProperties.has(nodeValue)) return;
complain('flow-relative', nodeValue, decl, index, endIndex);
} else {
if (!physicalToFlowRelativeProperties.has(nodeValue)) return;
if (!directionalMap) {
complain('physical', nodeValue, decl, index, endIndex);
return;
}
const expectedProp = directionalMap.get(nodeValue);
if (!expectedProp) return;
report({
message: messages.expected,
messageArgs: [nodeValue, expectedProp],
node: decl,
result,
ruleName,
index,
endIndex,
fix: {
apply: () => {
node.value = expectedProp;
setDeclarationValue(decl, parsedValue.toString());
},
node: decl,
},
});
}
});
});
};
};
rule.ruleName = ruleName;
rule.messages = messages;
rule.meta = meta;
export default rule;